merge: resolve ChannelView icon conflict, keep History + Search/Camera/ExternalLink/Link2
This commit is contained in:
+165
@@ -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) | 50–150 подписчиков | 1–2 недели |
|
||||||
|
| Reddit r/artificial | 50–200 переходов | На этой неделе |
|
||||||
|
| SEO (органика) | Долгосрочно | Само, 2–3 мес |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технический долг
|
||||||
|
|
||||||
|
- ENGINE_URL default в `lib/engine.js` tool'а = 3040, должен быть 3030.
|
||||||
|
- `per_day` в `autogen_settings` не используется в логике (только `run_hour:run_minute`).
|
||||||
|
- TOPIC_BANK заканчивается — нужен AI-генератор новых тем.
|
||||||
|
- VK: публикация без фото (нужен 2-step `photos.getWallUploadServer`).
|
||||||
|
- MAX: заглушка `throw new Error('не реализована')`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Не делаем
|
||||||
|
|
||||||
|
- Холст / визуальный редактор.
|
||||||
|
- Stories / Reels.
|
||||||
|
- Команда / роли (до 5+ клиентов).
|
||||||
|
- White Label.
|
||||||
|
- Парсинг конкурентов через MTProto.
|
||||||
@@ -0,0 +1,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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function PUT(req, { params }) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
try {
|
||||||
|
const { key } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const row = await engine.updateSetting(key, body?.value);
|
||||||
|
return NextResponse.json(row);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
// GET /api/admin/settings?category=photo_search
|
||||||
|
export async function GET(req) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const category = searchParams.get('category') || undefined;
|
||||||
|
const rows = await engine.listSettings(category);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -16,19 +16,33 @@ export async function POST(req) {
|
|||||||
}
|
}
|
||||||
const hash = await bcrypt.hash(password, 10);
|
const hash = await bcrypt.hash(password, 10);
|
||||||
const { rows } = await q(
|
const { rows } = await q(
|
||||||
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`,
|
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`,
|
||||||
[email, hash]
|
[email, hash]
|
||||||
);
|
);
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
const s = await getSession();
|
const s = await getSession();
|
||||||
s.userId = user.id;
|
s.userId = user.id;
|
||||||
s.email = user.email;
|
s.email = user.email;
|
||||||
|
s.isAdmin = !!user.is_admin;
|
||||||
await s.save();
|
await s.save();
|
||||||
return NextResponse.json({ ok: true, user });
|
|
||||||
|
// Инициализируем баланс нового пользователя (Free план, 50 кредитов)
|
||||||
|
try {
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
await fetch(`${ENGINE_URL}/api/billing/balance`, {
|
||||||
|
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, user, isNew: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// login
|
// login
|
||||||
const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]);
|
const { rows } = await q(
|
||||||
|
`SELECT id,email,password,name,is_admin FROM users WHERE email=$1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
|
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -41,6 +55,10 @@ export async function POST(req) {
|
|||||||
s.userId = user.id;
|
s.userId = user.id;
|
||||||
s.email = user.email;
|
s.email = user.email;
|
||||||
s.name = user.name;
|
s.name = user.name;
|
||||||
|
s.isAdmin = !!user.is_admin;
|
||||||
await s.save();
|
await s.save();
|
||||||
return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
|
}
|
||||||
@@ -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 }); }
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }); }
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export async function POST(req) {
|
|||||||
const channel = await engine.createChannel(user.id, body);
|
const channel = await engine.createChannel(user.id, body);
|
||||||
return NextResponse.json(channel);
|
return NextResponse.json(channel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
const status = err.status === 402 ? 402 : 500;
|
||||||
|
return NextResponse.json({ error: err.message, code: err.code }, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await engine.photoSearchByQuery(body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message, code: err.code },
|
||||||
|
{ status: err.status || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const data = await engine.photoSearchProfiles();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const data = await engine.photoSearchQuota();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -49,7 +49,8 @@ export default function NewChannelPage() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [niche, setNiche] = useState('');
|
const [niche, setNiche] = useState('');
|
||||||
const [audience, setAudience] = useState('');
|
const [audience, setAudience] = useState('');
|
||||||
const [goal, setGoal] = useState('educational');
|
const [goals, setGoals] = useState(['educational']); // multi-select, отправляем как CSV
|
||||||
|
const [customGoal, setCustomGoal] = useState(''); // поле для своей цели
|
||||||
const [language, setLanguage] = useState('ru');
|
const [language, setLanguage] = useState('ru');
|
||||||
|
|
||||||
// Шаг 2 — стиль
|
// Шаг 2 — стиль
|
||||||
@@ -70,7 +71,7 @@ export default function NewChannelPage() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
const data = {
|
const data = {
|
||||||
name, niche, audience, goal, language, region: 'ru',
|
name, niche, audience, goal: goals.join(','), language, region: 'ru',
|
||||||
style: {
|
style: {
|
||||||
tone, formality, humor,
|
tone, formality, humor,
|
||||||
post_length: postLength,
|
post_length: postLength,
|
||||||
@@ -88,7 +89,16 @@ export default function NewChannelPage() {
|
|||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (!res.ok) { setError(json.error || 'Ошибка'); return; }
|
if (!res.ok) {
|
||||||
|
if (json.code === 'CHANNEL_LIMIT_REACHED') {
|
||||||
|
setError(`${json.error} → `);
|
||||||
|
// Перенаправим на страницу тарифов через 2 сек
|
||||||
|
setTimeout(() => router.push('/plans'), 2000);
|
||||||
|
} else {
|
||||||
|
setError(json.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(`/channels/${json.id}`);
|
router.push(`/channels/${json.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,22 +160,67 @@ export default function NewChannelPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Цель канала</label>
|
<label className="label">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||||
{GOALS.map(g => (
|
{GOALS.map(g => {
|
||||||
<button
|
const on = goals.includes(g.v);
|
||||||
key={g.v}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setGoal(g.v)}
|
key={g.v}
|
||||||
className={`p-2.5 rounded-lg border text-left transition-colors ${
|
type="button"
|
||||||
goal === g.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
|
onClick={() => setGoals(on ? goals.filter(x => x !== g.v) : [...goals, g.v])}
|
||||||
}`}
|
className={`p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
>
|
on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
|
||||||
<div className="text-sm font-medium">{g.label}</div>
|
}`}
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
|
>
|
||||||
</button>
|
<div className="text-sm font-medium">{g.label}</div>
|
||||||
))}
|
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Своя цель */}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
className="input text-sm flex-1"
|
||||||
|
placeholder="Своя цель — введи и нажми +"
|
||||||
|
value={customGoal}
|
||||||
|
onChange={e => setCustomGoal(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const v = customGoal.trim();
|
||||||
|
if (v && !goals.includes(v)) setGoals([...goals, v]);
|
||||||
|
setCustomGoal('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const v = customGoal.trim();
|
||||||
|
if (v && !goals.includes(v)) setGoals([...goals, v]);
|
||||||
|
setCustomGoal('');
|
||||||
|
}}
|
||||||
|
disabled={!customGoal.trim()}
|
||||||
|
className="btn-primary px-3 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Выбранные кастомные цели — чипы */}
|
||||||
|
{goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{goals.filter(g => !GOALS.find(x => x.v === g)).map(g => (
|
||||||
|
<span key={g} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/15 border border-accent/40 text-xs">
|
||||||
|
{g}
|
||||||
|
<button type="button" onClick={() => setGoals(goals.filter(x => x !== g))} className="hover:text-red-400">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Язык постов</label>
|
<label className="label">Язык постов</label>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -27,7 +27,8 @@ export default function LoginPage() {
|
|||||||
setError(data.error || 'Ошибка');
|
setError(data.error || 'Ошибка');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push('/');
|
// Новый пользователь → онбординг, существующий → главная
|
||||||
|
router.push(data.isNew ? '/onboarding' : '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -15,7 +15,7 @@ const GOAL_LABELS = {
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
if (!user) redirect('/login');
|
if (!user) redirect('/landing');
|
||||||
|
|
||||||
let channels = [];
|
let channels = [];
|
||||||
try {
|
try {
|
||||||
@@ -43,50 +43,47 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
{channels.length === 0 ? (
|
{channels.length === 0 ? (
|
||||||
<div className="card p-12 text-center">
|
<div className="card p-12 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-surface2 mb-4">
|
<MessageSquare className="w-12 h-12 mx-auto mb-4 text-accent opacity-50" />
|
||||||
<MessageSquare className="w-7 h-7 text-gray-500" />
|
<h2 className="text-xl font-semibold mb-2">Нет каналов</h2>
|
||||||
</div>
|
<p className="text-gray-500 mb-6">Добавь первый канал чтобы начать генерировать контент</p>
|
||||||
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
Создай первый канал, чтобы начать генерировать посты
|
|
||||||
</p>
|
|
||||||
<Link href="/channels/new" className="btn-primary">
|
<Link href="/channels/new" className="btn-primary">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Создать канал
|
Создать первый канал
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map(ch => (
|
{channels.map(ch => (
|
||||||
<Link
|
<Link key={ch.id} href={`/channels/${ch.id}`} className="card p-5 hover:border-accent/40 transition-colors group">
|
||||||
key={ch.id}
|
|
||||||
href={`/channels/${ch.id}`}
|
|
||||||
className="card p-5 hover:border-accent/40 transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="font-semibold group-hover:text-accent transition-colors">
|
<div>
|
||||||
{ch.name}
|
<h3 className="font-semibold group-hover:text-accent transition-colors">{ch.name}</h3>
|
||||||
</h3>
|
{ch.tg_username && (
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
|
<span className="text-xs text-gray-500">@{ch.tg_username}</span>
|
||||||
{GOAL_LABELS[ch.goal] || ch.goal}
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
ch.platform === 'telegram' ? 'bg-blue-500/20 text-blue-400' :
|
||||||
|
ch.platform === 'vk' ? 'bg-blue-600/20 text-blue-500' :
|
||||||
|
'bg-purple-500/20 text-purple-400'
|
||||||
|
}`}>
|
||||||
|
{ch.platform || 'telegram'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ch.niche && (
|
{ch.niche && (
|
||||||
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
|
<p className="text-sm text-gray-400 mb-3 line-clamp-2">{ch.niche}</p>
|
||||||
{ch.niche}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||||
{ch.audience && (
|
{ch.goal && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="w-3 h-3" />
|
<Target className="w-3 h-3" />
|
||||||
Есть ЦА
|
{GOAL_LABELS[ch.goal] || ch.goal}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{ch.style?.example_posts?.length > 0 && (
|
{ch.language && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Target className="w-3 h-3 text-accent" />
|
<Users className="w-3 h-3" />
|
||||||
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'}
|
{ch.language.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -2,7 +2,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette } from 'lucide-react';
|
import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette, Plus, X, Sparkles, Plug } from 'lucide-react';
|
||||||
|
import TopicBank from './TopicBank';
|
||||||
|
|
||||||
|
const GOALS = [
|
||||||
|
{ v: 'educational', label: 'Обучение', desc: 'Объясняем, разбираем' },
|
||||||
|
{ v: 'news', label: 'Новости', desc: 'Что произошло' },
|
||||||
|
{ v: 'entertainment', label: 'Развлечение', desc: 'Лёгкий контент, мемы' },
|
||||||
|
{ v: 'expert', label: 'Экспертный', desc: 'Глубокий анализ, инсайты' },
|
||||||
|
{ v: 'sales', label: 'Продажи', desc: 'Подвести к покупке' },
|
||||||
|
];
|
||||||
|
|
||||||
const TONES = [
|
const TONES = [
|
||||||
{ v: 'friendly', label: 'Дружелюбный' },
|
{ v: 'friendly', label: 'Дружелюбный' },
|
||||||
@@ -32,14 +41,14 @@ const EMOJI = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const IMAGE_STYLES = [
|
const IMAGE_STYLES = [
|
||||||
{ v: 'realistic-photo', label: 'Реалистичное фото', desc: 'Стоковая фотография' },
|
{ v: 'realistic-photo', label: 'Реалистичное фото', desc: 'AI-фотореализм, не сток' },
|
||||||
{ v: 'flat-illustration', label: 'Плоская иллюстрация', desc: 'Editorial vector' },
|
{ v: 'flat-illustration',label: 'Плоская иллюстрация', desc: 'Editorial vector' },
|
||||||
{ v: '3d-render', label: '3D рендер', desc: 'Pixar-like' },
|
{ v: '3d-render', label: '3D рендер', desc: 'Pixar-like' },
|
||||||
{ v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' },
|
{ v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' },
|
||||||
{ v: 'minimal', label: 'Минимализм', desc: 'Один элемент' },
|
{ v: 'minimal', label: 'Минимализм', desc: 'Один элемент' },
|
||||||
{ v: 'abstract', label: 'Абстракция', desc: 'Без объектов' },
|
{ v: 'abstract', label: 'Абстракция', desc: 'Геометрия, настроение' },
|
||||||
{ v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' },
|
{ v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' },
|
||||||
{ v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' },
|
{ v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const IMAGE_PALETTES = [
|
const IMAGE_PALETTES = [
|
||||||
@@ -53,8 +62,10 @@ const IMAGE_PALETTES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'content', label: 'Контент', icon: Type },
|
{ id: 'content', label: 'Контент', icon: Type },
|
||||||
{ id: 'images', label: 'Картинки', icon: ImageIcon },
|
{ id: 'images', label: 'Картинки', icon: ImageIcon },
|
||||||
|
{ id: 'ai', label: 'AI-стиль', icon: Sparkles },
|
||||||
|
{ id: 'connect', label: 'Подключение', icon: Plug },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ChannelEdit({ channel }) {
|
export default function ChannelEdit({ channel }) {
|
||||||
@@ -66,6 +77,11 @@ export default function ChannelEdit({ channel }) {
|
|||||||
const [name, setName] = useState(channel.name || '');
|
const [name, setName] = useState(channel.name || '');
|
||||||
const [niche, setNiche] = useState(channel.niche || '');
|
const [niche, setNiche] = useState(channel.niche || '');
|
||||||
const [audience, setAudience] = useState(channel.audience || '');
|
const [audience, setAudience] = useState(channel.audience || '');
|
||||||
|
const [goals, setGoals] = useState(
|
||||||
|
channel.goal ? channel.goal.split(',').map(g => g.trim()).filter(Boolean) : ['educational']
|
||||||
|
);
|
||||||
|
const [customGoal, setCustomGoal] = useState('');
|
||||||
|
const [language, setLanguage] = useState(channel.language || 'ru');
|
||||||
const [tone, setTone] = useState(style.tone || 'friendly');
|
const [tone, setTone] = useState(style.tone || 'friendly');
|
||||||
const [formality, setFormality] = useState(style.formality || 'informal');
|
const [formality, setFormality] = useState(style.formality || 'informal');
|
||||||
const [humor, setHumor] = useState(style.humor || 'moderate');
|
const [humor, setHumor] = useState(style.humor || 'moderate');
|
||||||
@@ -77,9 +93,28 @@ export default function ChannelEdit({ channel }) {
|
|||||||
|
|
||||||
// Картинки
|
// Картинки
|
||||||
const [imageEnabled, setImageEnabled] = useState(style.image_enabled ?? false);
|
const [imageEnabled, setImageEnabled] = useState(style.image_enabled ?? false);
|
||||||
const [imageStyle, setImageStyle] = useState(style.image_style || 'flat-illustration');
|
const [imageStyles, setImageStyles] = useState(
|
||||||
|
(style.image_style || 'flat-illustration').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
);
|
||||||
const [imagePalette, setImagePalette] = useState(style.image_palette || 'auto');
|
const [imagePalette, setImagePalette] = useState(style.image_palette || 'auto');
|
||||||
const [imageCustomColors, setImageCustomColors] = useState(style.image_custom_colors || '');
|
const [imageCustomColors, setImageCustomColors] = useState(style.image_custom_colors || '');
|
||||||
|
const [imagePromptInstructions, setImagePromptInstructions] = useState(style.image_prompt_instructions || '');
|
||||||
|
|
||||||
|
// Подключение
|
||||||
|
const [botToken, setBotToken] = useState(channel.bot_token || '');
|
||||||
|
const [tgChannelId, setTgChannelId] = useState(channel.tg_channel_id || '');
|
||||||
|
const [tgUsername, setTgUsername] = useState(channel.tg_username || '');
|
||||||
|
const [vkToken, setVkToken] = useState(channel.vk_access_token || '');
|
||||||
|
const [tokenVerifying, setTokenVerifying] = useState(false);
|
||||||
|
const [tokenStatus, setTokenStatus] = useState(null); // null | 'ok' | 'error'
|
||||||
|
|
||||||
|
// AI-стиль
|
||||||
|
const [aiStylePrompt, setAiStylePrompt] = useState(channel.ai_style_prompt || '');
|
||||||
|
const [imageQuality, setImageQuality] = useState(channel.image_quality || 'standard');
|
||||||
|
// Авто-черновики
|
||||||
|
const [autoDraftEnabled, setAutoDraftEnabled] = useState(channel.auto_draft_enabled || false);
|
||||||
|
const [autoDraftCount, setAutoDraftCount] = useState(channel.auto_draft_count || 3);
|
||||||
|
const [autoDraftTime, setAutoDraftTime] = useState(channel.auto_draft_time || '08:00');
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -91,7 +126,16 @@ export default function ChannelEdit({ channel }) {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
name, niche, audience,
|
name, niche, audience, goal: goals.join(','), language,
|
||||||
|
bot_token: botToken.trim() || null,
|
||||||
|
tg_channel_id: tgChannelId.trim() || null,
|
||||||
|
tg_username: tgUsername.trim() || null,
|
||||||
|
vk_access_token: vkToken.trim() || null,
|
||||||
|
ai_style_prompt: aiStylePrompt.trim() || null,
|
||||||
|
image_quality: imageQuality,
|
||||||
|
auto_draft_enabled: autoDraftEnabled,
|
||||||
|
auto_draft_count: autoDraftCount,
|
||||||
|
auto_draft_time: autoDraftTime,
|
||||||
style: {
|
style: {
|
||||||
tone, formality, humor,
|
tone, formality, humor,
|
||||||
post_length: postLength,
|
post_length: postLength,
|
||||||
@@ -100,9 +144,10 @@ export default function ChannelEdit({ channel }) {
|
|||||||
banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean),
|
banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean),
|
banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
image_enabled: imageEnabled,
|
image_enabled: imageEnabled,
|
||||||
image_style: imageStyle,
|
image_style: imageStyles.join(','),
|
||||||
image_palette: imagePalette,
|
image_palette: imagePalette,
|
||||||
image_custom_colors: imageCustomColors.trim() || null,
|
image_custom_colors: imageCustomColors.trim() || null,
|
||||||
|
image_prompt_instructions: imagePromptInstructions.trim() || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await fetch(`/api/channels/${channel.id}`, {
|
const res = await fetch(`/api/channels/${channel.id}`, {
|
||||||
@@ -183,6 +228,47 @@ export default function ChannelEdit({ channel }) {
|
|||||||
<label className="label">Аудитория</label>
|
<label className="label">Аудитория</label>
|
||||||
<textarea className="input min-h-[70px]" value={audience} onChange={e => setAudience(e.target.value)} />
|
<textarea className="input min-h-[70px]" value={audience} onChange={e => setAudience(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-2">
|
||||||
|
{GOALS.map(g => {
|
||||||
|
const on = goals.includes(g.v);
|
||||||
|
return (
|
||||||
|
<button key={g.v} type="button"
|
||||||
|
onClick={() => setGoals(on ? goals.filter(x => x !== g.v) : [...goals, g.v])}
|
||||||
|
className={`p-2.5 rounded-lg border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'}`}>
|
||||||
|
<div className="text-sm font-medium">{g.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className="input text-sm flex-1" placeholder="Своя цель — введи и нажми +"
|
||||||
|
value={customGoal} onChange={e => setCustomGoal(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); const v = customGoal.trim(); if (v && !goals.includes(v)) setGoals([...goals, v]); setCustomGoal(''); }}} />
|
||||||
|
<button type="button" onClick={() => { const v = customGoal.trim(); if (v && !goals.includes(v)) setGoals([...goals, v]); setCustomGoal(''); }}
|
||||||
|
disabled={!customGoal.trim()} className="btn-primary px-3 disabled:opacity-40"><Plus className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
{goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{goals.filter(g => !GOALS.find(x => x.v === g)).map(g => (
|
||||||
|
<span key={g} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/15 border border-accent/40 text-xs">
|
||||||
|
{g}<button type="button" onClick={() => setGoals(goals.filter(x => x !== g))}><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Язык постов</label>
|
||||||
|
<select className="input" value={language} onChange={e => setLanguage(e.target.value)}>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="uk">Українська</option>
|
||||||
|
<option value="kk">Қазақша</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-5 space-y-4">
|
<div className="card p-5 space-y-4">
|
||||||
@@ -281,22 +367,31 @@ export default function ChannelEdit({ channel }) {
|
|||||||
{imageEnabled && (
|
{imageEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<h3 className="font-semibold text-sm mb-3 flex items-center gap-2">
|
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2">
|
||||||
<ImageIcon className="w-4 h-4 text-accent" />
|
<ImageIcon className="w-4 h-4 text-accent" />
|
||||||
Стиль изображений
|
Стиль изображений
|
||||||
|
<span className="text-gray-500 font-normal">(можно несколько — система будет чередовать)</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Все стили — это <b>AI-генерация</b>, не стоковые фото.
|
||||||
|
Если в посте упоминается реальный человек — система автоматически ищет его фото в интернете вместо генерации.
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
{IMAGE_STYLES.map(s => (
|
{IMAGE_STYLES.map(s => {
|
||||||
<button
|
const on = imageStyles.includes(s.v);
|
||||||
key={s.v} type="button" onClick={() => setImageStyle(s.v)}
|
return (
|
||||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
<button
|
||||||
imageStyle === s.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
|
key={s.v} type="button"
|
||||||
}`}
|
onClick={() => setImageStyles(on ? imageStyles.filter(x => x !== s.v) : [...imageStyles, s.v])}
|
||||||
>
|
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||||
<div className="text-sm font-medium">{s.label}</div>
|
on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div>
|
}`}
|
||||||
</button>
|
>
|
||||||
))}
|
<div className="text-sm font-medium">{s.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -329,6 +424,26 @@ export default function ChannelEdit({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Инструкции для AI */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
Инструкции для AI
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Опиши, какими должны быть картинки. Например: <em>«тёмный фон, минималистичные 3D-объекты, технологичная эстетика, без людей»</em>.
|
||||||
|
Применяется ко всем постам и обложкам статей этого канала.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className="input min-h-[90px] text-sm"
|
||||||
|
value={imagePromptInstructions}
|
||||||
|
onChange={e => setImagePromptInstructions(e.target.value)}
|
||||||
|
placeholder="Примеры: — технологичный объект на тёмном градиентном фоне, как Stripe/Vercel blog — реалистичное фото молочного производства, без людей, фокус на деталях — мягкая пастельная акварель, природа и животные, тёплый тон"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500 text-right mt-1">{imagePromptInstructions.length}/500</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preview подсказка */}
|
{/* Preview подсказка */}
|
||||||
<div className="card p-4 bg-accent/5 border-accent/20 text-sm text-gray-300">
|
<div className="card p-4 bg-accent/5 border-accent/20 text-sm text-gray-300">
|
||||||
<div className="font-medium text-accent mb-1">Как это работает</div>
|
<div className="font-medium text-accent mb-1">Как это работает</div>
|
||||||
@@ -341,6 +456,152 @@ export default function ChannelEdit({ channel }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TAB: AI-стиль */}
|
||||||
|
{tab === 'ai' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Промт для генерации статей */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
<h3 className="font-semibold text-sm">Стиль генерации статей</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Дополнительные инструкции для AI при автоматической генерации статей в этом канале.
|
||||||
|
Применяется ко всем статьям канала. При ручной генерации можно переопределить.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
placeholder={`Например:\n• Пиши в стиле новостной заметки, без воды\n• Аудитория: молочные фермеры Сибири\n• Всегда заканчивай призывом к действию\n• Включай конкретные цифры и факты`}
|
||||||
|
value={aiStylePrompt}
|
||||||
|
onChange={e => setAiStylePrompt(e.target.value)}
|
||||||
|
className="input w-full text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{aiStylePrompt.length}/1000 символов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модель генерации картинок — только gpt-5-image-mini */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ImageIcon className="w-4 h-4 text-accent" />
|
||||||
|
<h3 className="font-semibold text-sm">Генерация картинок</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center text-base">🖼</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">gpt-5-image-mini</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">routerai.ru • ~₽2.72/картинка • high quality</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">Активна</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Единственная модель для генерации изображений. Параметр качества фиксирован провайдером.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Банк тем */}
|
||||||
|
<TopicBank channelId={channel.id} />
|
||||||
|
|
||||||
|
{/* Авто-черновики */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
<span>⚡</span> Авто-генерация черновиков
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Система генерирует посты каждый день — ты одобряешь вечером
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer"
|
||||||
|
checked={autoDraftEnabled}
|
||||||
|
onChange={e => setAutoDraftEnabled(e.target.checked)} />
|
||||||
|
<div className="w-10 h-5 bg-gray-600 peer-focus:outline-none rounded-full peer
|
||||||
|
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||||
|
after:content-[''] after:absolute after:top-0.5 after:left-[2px]
|
||||||
|
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
|
||||||
|
peer-checked:bg-accent" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{autoDraftEnabled && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs mb-1">Постов в день</label>
|
||||||
|
<select value={autoDraftCount} onChange={e => setAutoDraftCount(+e.target.value)}
|
||||||
|
className="input w-full text-sm py-1.5">
|
||||||
|
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n} {n===1?'пост':'постов'}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs mb-1">Время генерации</label>
|
||||||
|
<input type="time" value={autoDraftTime}
|
||||||
|
onChange={e => setAutoDraftTime(e.target.value)}
|
||||||
|
className="input w-full text-sm py-1.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Черновики появляются на странице{' '}
|
||||||
|
<a href="/drafts" target="_blank" className="text-accent hover:underline">Черновики</a>.
|
||||||
|
Там можно редактировать, одобрять и планировать публикацию.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB: Подключение */}
|
||||||
|
{tab === 'connect' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg">✈️</span>
|
||||||
|
<h3 className="font-semibold">Telegram</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface2/50 rounded-lg p-3 text-xs text-gray-400 space-y-1 border border-border">
|
||||||
|
<div className="font-medium text-gray-300 mb-2">Как подключить:</div>
|
||||||
|
<div>1. Создай бота через <span className="text-accent">@BotFather</span> → <code>/newbot</code> → скопируй токен</div>
|
||||||
|
<div>2. Добавь бота <b>администратором</b> в свой канал (права: публикация сообщений)</div>
|
||||||
|
<div>3. Узнай ID канала: перешли сообщение из канала боту <span className="text-accent">@idbot</span> — вернёт ID вида <code>-100xxxxxxxxxx</code></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Bot Token</label>
|
||||||
|
<input className="input font-mono text-sm" placeholder="7123456789:AAHxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
value={botToken} onChange={e => { setBotToken(e.target.value); setTokenStatus(null); }} type="password" />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">ID канала</label>
|
||||||
|
<input className="input font-mono text-sm" placeholder="-1001234567890"
|
||||||
|
value={tgChannelId} onChange={e => setTgChannelId(e.target.value)} />
|
||||||
|
<div className="hint">Начинается с -100</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Username канала</label>
|
||||||
|
<input className="input font-mono text-sm" placeholder="@mychannel"
|
||||||
|
value={tgUsername} onChange={e => setTgUsername(e.target.value)} />
|
||||||
|
<div className="hint">Необязательно если заполнен ID</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg">🅱️</span>
|
||||||
|
<h3 className="font-semibold">ВКонтакте</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Access Token группы</label>
|
||||||
|
<input className="input font-mono text-sm" placeholder="vk1.a.xxx..."
|
||||||
|
value={vkToken} onChange={e => setVkToken(e.target.value)} type="password" />
|
||||||
|
<div className="hint">Управление → API → Ключи доступа → Создать ключ (права: wall, photos)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+330
-47
@@ -4,8 +4,17 @@ import Link from 'next/link';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
|
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
|
||||||
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
|
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
|
||||||
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History
|
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History,
|
||||||
|
Search, Camera, ExternalLink, Link2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import PhotoSearchModal from './PhotoSearchModal';
|
||||||
|
import PostPreview from './PostPreview';
|
||||||
|
import PostTemplates from './PostTemplates';
|
||||||
|
import ChannelAnalytics from './ChannelAnalytics';
|
||||||
|
import FromUrlModal from './FromUrlModal';
|
||||||
|
import PollModal from './PollModal';
|
||||||
|
import HashtagSuggest from './HashtagSuggest';
|
||||||
|
import InboxTab from './InboxTab';
|
||||||
|
|
||||||
const GOAL_LABELS = {
|
const GOAL_LABELS = {
|
||||||
educational: 'Обучение', news: 'Новости',
|
educational: 'Обучение', news: 'Новости',
|
||||||
@@ -22,8 +31,19 @@ const TRANSFORMS = [
|
|||||||
{ action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
|
{ action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Хвостовая подпись «📷 Фото: domain» — добавляется к посту, можно убрать
|
||||||
|
function buildCaption(domain) {
|
||||||
|
return domain ? `\n\n📷 Фото: ${domain}` : '';
|
||||||
|
}
|
||||||
|
// Удаляет существующую подпись из текста поста (по любому домену)
|
||||||
|
function stripCaption(text) {
|
||||||
|
return (text || '').replace(/\n{1,2}📷\s*Фото:\s*[^\n]+\s*$/u, '').trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChannelView({ channel }) {
|
export default function ChannelView({ channel }) {
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
|
const [customPrompt, setCustomPrompt] = useState('');
|
||||||
|
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [post, setPost] = useState(null);
|
const [post, setPost] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -36,8 +56,16 @@ export default function ChannelView({ channel }) {
|
|||||||
|
|
||||||
// Картинка
|
// Картинка
|
||||||
const [image, setImage] = useState(null);
|
const [image, setImage] = useState(null);
|
||||||
|
const [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null
|
||||||
const [genImage, setGenImage] = useState(false);
|
const [genImage, setGenImage] = useState(false);
|
||||||
|
|
||||||
|
// Photo search modal
|
||||||
|
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
|
||||||
|
const [showFromUrl, setShowFromUrl] = useState(false);
|
||||||
|
const [showPoll, setShowPoll] = useState(false);
|
||||||
|
const [batchCount, setBatchCount] = useState(3);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
// Трансформации
|
// Трансформации
|
||||||
const [transforming, setTransforming] = useState(false);
|
const [transforming, setTransforming] = useState(false);
|
||||||
|
|
||||||
@@ -69,12 +97,12 @@ export default function ChannelView({ channel }) {
|
|||||||
// Сохранение и публикация
|
// Сохранение и публикация
|
||||||
const [savedPostId, setSavedPostId] = useState(null);
|
const [savedPostId, setSavedPostId] = useState(null);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('generate'); // generate | analytics
|
||||||
const [showScheduler, setShowScheduler] = useState(false);
|
const [showScheduler, setShowScheduler] = useState(false);
|
||||||
const [scheduleAt, setScheduleAt] = useState('');
|
const [scheduleAt, setScheduleAt] = useState('');
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
// Подгрузка истории при монтировании
|
|
||||||
useEffect(() => { loadHistory(); }, []);
|
useEffect(() => { loadHistory(); }, []);
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
@@ -86,6 +114,30 @@ export default function ChannelView({ channel }) {
|
|||||||
} catch {} finally { setLoadingHistory(false); }
|
} catch {} finally { setLoadingHistory(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
|
// Если в посте была подпись «📷 Фото: …» — убираем её при удалении фото
|
||||||
|
if (post) setPost(p => stripCaption(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPhotoPick({ imageUrl, credit }) {
|
||||||
|
setImage(imageUrl);
|
||||||
|
setImageCredit(credit || null);
|
||||||
|
// Подменяем (или добавляем) caption
|
||||||
|
if (post && credit?.domain) {
|
||||||
|
setPost(p => stripCaption(p) + buildCaption(credit.domain));
|
||||||
|
}
|
||||||
|
setShowPhotoSearch(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFromUrl({ content, imageUrl, title }) {
|
||||||
|
setPost(content);
|
||||||
|
if (imageUrl) setImage(imageUrl);
|
||||||
|
if (title && !topic.trim()) setTopic(title.slice(0, 120));
|
||||||
|
setSavedPostId(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function savePost(status = 'draft', scheduledAt = null) {
|
async function savePost(status = 'draft', scheduledAt = null) {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
@@ -93,12 +145,12 @@ export default function ChannelView({ channel }) {
|
|||||||
try {
|
try {
|
||||||
let id = savedPostId;
|
let id = savedPostId;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
// Создаём
|
|
||||||
const res = await fetch('/api/user-posts', {
|
const res = await fetch('/api/user-posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channel_id: channel.id, content: post, image_url: image,
|
channel_id: channel.id, content: post,
|
||||||
|
image_url: image, image_credit: imageCredit,
|
||||||
topic: topic.trim(), status, scheduled_at: scheduledAt,
|
topic: topic.trim(), status, scheduled_at: scheduledAt,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -107,11 +159,15 @@ export default function ChannelView({ channel }) {
|
|||||||
id = data.id;
|
id = data.id;
|
||||||
setSavedPostId(id);
|
setSavedPostId(id);
|
||||||
} else {
|
} else {
|
||||||
// Обновляем
|
|
||||||
const res = await fetch(`/api/user-posts/${id}`, {
|
const res = await fetch(`/api/user-posts/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }),
|
body: JSON.stringify({
|
||||||
|
content: post,
|
||||||
|
image_url: image,
|
||||||
|
image_credit: imageCredit,
|
||||||
|
status, scheduled_at: scheduledAt,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
|
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
|
||||||
}
|
}
|
||||||
@@ -137,6 +193,7 @@ export default function ChannelView({ channel }) {
|
|||||||
setPost(null);
|
setPost(null);
|
||||||
setSavedPostId(null);
|
setSavedPostId(null);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
setTopic('');
|
setTopic('');
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
finally { setPublishing(false); }
|
finally { setPublishing(false); }
|
||||||
@@ -151,9 +208,18 @@ export default function ChannelView({ channel }) {
|
|||||||
setPost(null);
|
setPost(null);
|
||||||
setSavedPostId(null);
|
setSavedPostId(null);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
setTopic('');
|
setTopic('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTemplate({ topicHint, structure }) {
|
||||||
|
// Если тема не задана — подставляем подсказку
|
||||||
|
if (!topic.trim()) setTopic(topicHint);
|
||||||
|
// В textarea вставляем структуру как отправную точку
|
||||||
|
setPost(structure);
|
||||||
|
setSavedPostId(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function generate(asVariant = false) {
|
async function generate(asVariant = false) {
|
||||||
if (!topic.trim() && !asVariant) return;
|
if (!topic.trim() && !asVariant) return;
|
||||||
if (asVariant && !post) return;
|
if (asVariant && !post) return;
|
||||||
@@ -168,10 +234,18 @@ export default function ChannelView({ channel }) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
|
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
|
||||||
|
customPrompt: customPrompt.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const job = await createRes.json();
|
const job = await createRes.json();
|
||||||
if (!createRes.ok) throw new Error(job.error || 'Ошибка');
|
if (!createRes.ok) {
|
||||||
|
if (job.code === 'INSUFFICIENT_CREDITS') {
|
||||||
|
throw new Error(`Недостаточно кредитов: нужно ${job.cost}, есть ${job.credits}. Пополните баланс на странице тарифов.`);
|
||||||
|
}
|
||||||
|
throw new Error(job.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
// Триггер обновления баланса в header
|
||||||
|
if (job.credits_after !== null) window.dispatchEvent(new Event('credits-updated'));
|
||||||
|
|
||||||
let final;
|
let final;
|
||||||
for (let i = 0; i < 60; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
@@ -183,14 +257,14 @@ export default function ChannelView({ channel }) {
|
|||||||
if (!final) throw new Error('Таймаут — попробуй ещё раз');
|
if (!final) throw new Error('Таймаут — попробуй ещё раз');
|
||||||
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
|
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
|
||||||
|
|
||||||
// Сохраняем предыдущий вариант в variants
|
|
||||||
if (asVariant && post) {
|
if (asVariant && post) {
|
||||||
setVariants(v => [...v, { content: post, tokens, image }]);
|
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPost(final.result);
|
setPost(final.result);
|
||||||
setTokens({ in: final.tokens_in, out: final.tokens_out });
|
setTokens({ in: final.tokens_in, out: final.tokens_out });
|
||||||
setImage(null); // сбрасываем картинку при новом посте
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -210,10 +284,10 @@ export default function ChannelView({ channel }) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Ошибка');
|
if (!res.ok) throw new Error(data.error || 'Ошибка');
|
||||||
// Сохраняем текущий в варианты
|
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
|
||||||
setVariants(v => [...v, { content: post, tokens, image }]);
|
|
||||||
setPost(data.content);
|
setPost(data.content);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -234,6 +308,7 @@ export default function ChannelView({ channel }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
|
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
|
||||||
setImage(data.url);
|
setImage(data.url);
|
||||||
|
setImageCredit(null); // сгенерированная — без credit'а
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,12 +320,13 @@ export default function ChannelView({ channel }) {
|
|||||||
const v = variants[idx];
|
const v = variants[idx];
|
||||||
setVariants(arr => {
|
setVariants(arr => {
|
||||||
const next = arr.filter((_, i) => i !== idx);
|
const next = arr.filter((_, i) => i !== idx);
|
||||||
next.push({ content: post, tokens, image });
|
next.push({ content: post, tokens, image, imageCredit });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setPost(v.content);
|
setPost(v.content);
|
||||||
setTokens(v.tokens);
|
setTokens(v.tokens);
|
||||||
setImage(v.image);
|
setImage(v.image);
|
||||||
|
setImageCredit(v.imageCredit || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
@@ -271,7 +347,7 @@ export default function ChannelView({ channel }) {
|
|||||||
<Sparkles className="w-5 h-5 text-accent" />
|
<Sparkles className="w-5 h-5 text-accent" />
|
||||||
<h1 className="text-2xl font-bold">{channel.name}</h1>
|
<h1 className="text-2xl font-bold">{channel.name}</h1>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
|
||||||
{GOAL_LABELS[channel.goal] || channel.goal}
|
{(channel.goal || '').split(',').map(g => GOAL_LABELS[g.trim()] || g.trim()).join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
|
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
|
||||||
@@ -286,6 +362,26 @@ export default function ChannelView({ channel }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Вкладки */}
|
||||||
|
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border self-start mb-2">
|
||||||
|
{[['generate','Создать пост'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (
|
||||||
|
<button key={id} onClick={() => setActiveTab(id)}
|
||||||
|
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||||
|
${activeTab===id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'inbox' && (
|
||||||
|
<InboxTab channel={channel} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'generate' && <>
|
||||||
{/* Generator */}
|
{/* Generator */}
|
||||||
<div className="card p-5 mb-6">
|
<div className="card p-5 mb-6">
|
||||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
@@ -293,17 +389,60 @@ export default function ChannelView({ channel }) {
|
|||||||
<Wand2 className="w-4 h-4 text-accent" />
|
<Wand2 className="w-4 h-4 text-accent" />
|
||||||
Сгенерировать пост
|
Сгенерировать пост
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={fetchIdeas}
|
<PostTemplates onSelect={applyTemplate} disabled={generating} />
|
||||||
disabled={loadingIdeas}
|
<button
|
||||||
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50"
|
onClick={() => setShowFromUrl(true)}
|
||||||
>
|
className="text-xs inline-flex items-center gap-1 text-accent hover:underline"
|
||||||
{loadingIdeas ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
>
|
||||||
Идеи тем
|
<Link2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
По ссылке
|
||||||
|
</button>
|
||||||
|
{channel.platform === 'telegram' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPoll(true)}
|
||||||
|
className="text-xs inline-flex items-center gap-1 text-gray-400 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span>📊</span>
|
||||||
|
Опрос
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Batch-генерация черновиков */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setBatchLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channel.id}/drafts/generate?count=${batchCount}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (res.ok) alert(`✅ Генерирую ${batchCount} черновиков — через несколько минут появятся в /drafts`);
|
||||||
|
else alert(res.error || 'Ошибка');
|
||||||
|
} catch { alert('Ошибка'); }
|
||||||
|
setBatchLoading(false);
|
||||||
|
}}
|
||||||
|
disabled={batchLoading}
|
||||||
|
className="text-xs inline-flex items-center gap-1 text-purple-400 hover:text-purple-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{batchLoading ? '⏳' : '⚡'}</span>
|
||||||
|
Авто ×
|
||||||
|
</button>
|
||||||
|
<select value={batchCount} onChange={e => setBatchCount(+e.target.value)}
|
||||||
|
className="text-xs bg-surface2 border border-border rounded px-1 py-0.5 text-gray-400">
|
||||||
|
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchIdeas}
|
||||||
|
disabled={loadingIdeas}
|
||||||
|
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingIdeas ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||||
|
Идеи тем
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Список идей */}
|
|
||||||
{showIdeas && ideas.length > 0 && (
|
{showIdeas && ideas.length > 0 && (
|
||||||
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
|
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -327,15 +466,49 @@ export default function ChannelView({ channel }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="input min-h-[80px] mb-3"
|
className="input min-h-[80px] mb-2"
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={e => setTopic(e.target.value)}
|
onChange={e => setTopic(e.target.value)}
|
||||||
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
|
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Дополнительные инструкции */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomPrompt(v => !v)}
|
||||||
|
className="text-xs text-gray-500 hover:text-accent flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{showCustomPrompt ? '▾' : '▸'}</span>
|
||||||
|
Дополнительные инструкции для AI
|
||||||
|
{customPrompt.trim() && <span className="ml-1 w-1.5 h-1.5 rounded-full bg-accent inline-block" />}
|
||||||
|
</button>
|
||||||
|
{showCustomPrompt && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="input w-full text-sm resize-none"
|
||||||
|
placeholder={`Например: «Сделай акцент на кейсах из сельского хозяйства» или «Добавь призыв подписаться в конце»`}
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={e => setCustomPrompt(e.target.value)}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Перебивает стиль канала для этой генерации.
|
||||||
|
{channel.ai_style_prompt && ' Канальный промт также будет применён.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500 space-y-0.5">
|
||||||
ИИ напишет пост в стиле твоего канала с учётом примеров
|
<div>ИИ напишет пост в стиле твоего канала с учётом примеров</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-600">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface2 text-[11px]">2 кр — текст</span>
|
||||||
|
{channel.image_enabled && <span className="px-1.5 py-0.5 rounded bg-surface2 text-[11px]">+ 5 кр — картинка</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
|
<button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
|
||||||
{generating ? (
|
{generating ? (
|
||||||
@@ -354,7 +527,8 @@ export default function ChannelView({ channel }) {
|
|||||||
|
|
||||||
{/* Result */}
|
{/* Result */}
|
||||||
{post && (
|
{post && (
|
||||||
<div className="card p-5 mb-4">
|
<div className="grid lg:grid-cols-[1fr_360px] gap-4 mb-4 items-start">
|
||||||
|
<div className="card p-5">
|
||||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
<h3 className="font-semibold flex items-center gap-2">
|
<h3 className="font-semibold flex items-center gap-2">
|
||||||
Результат
|
Результат
|
||||||
@@ -388,7 +562,6 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Сам пост — редактируемый или нет */}
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={post}
|
value={post}
|
||||||
@@ -402,32 +575,93 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Картинка к посту */}
|
{/* Хештеги */}
|
||||||
|
<HashtagSuggest
|
||||||
|
channelId={channel.id}
|
||||||
|
postText={post}
|
||||||
|
onAppend={text => setPost(p => (p || '') + text)}
|
||||||
|
/>
|
||||||
{image && (
|
{image && (
|
||||||
<div className="mt-4 relative">
|
<div className="mt-4">
|
||||||
<img src={image} alt="" className="w-full rounded-lg" />
|
<div className="relative">
|
||||||
|
<img src={image} alt="" className="w-full rounded-lg" referrerPolicy="no-referrer" />
|
||||||
|
<button
|
||||||
|
onClick={clearImage}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
|
||||||
|
title="Убрать картинку"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{imageCredit?.domain && (
|
||||||
|
<div className="mt-2 flex items-center justify-between flex-wrap gap-2 text-xs">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
📷 Фото:{' '}
|
||||||
|
{imageCredit.sourceUrl ? (
|
||||||
|
<a
|
||||||
|
href={imageCredit.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{imageCredit.domain}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-accent">{imageCredit.domain}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPost(p => stripCaption(p))}
|
||||||
|
className="text-gray-500 hover:text-gray-300"
|
||||||
|
title="Убрать подпись «📷 Фото: …» из текста поста (саму картинку оставить)"
|
||||||
|
>
|
||||||
|
убрать подпись из поста
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопки получения картинки */}
|
||||||
|
{!image && (
|
||||||
|
<div className="mt-4 grid sm:grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setImage(null)}
|
onClick={() => setShowPhotoSearch(true)}
|
||||||
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
|
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<Search className="w-4 h-4" /> Найти фото
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={generateImage}
|
||||||
|
disabled={genImage}
|
||||||
|
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{genImage ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую… (~30 сек)</>
|
||||||
|
) : (
|
||||||
|
<><Camera className="w-4 h-4" /> Сгенерировать (AI)</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Кнопка генерации картинки */}
|
{/* Если уже есть картинка — даём ещё раз поменять */}
|
||||||
{!image && (
|
{image && (
|
||||||
<div className="mt-4">
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPhotoSearch(true)}
|
||||||
|
className="btn-ghost text-xs py-1"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" /> Другое фото
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={generateImage}
|
onClick={generateImage}
|
||||||
disabled={genImage}
|
disabled={genImage}
|
||||||
className="w-full inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
|
className="btn-ghost text-xs py-1"
|
||||||
>
|
>
|
||||||
{genImage ? (
|
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
|
||||||
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</>
|
Заменить AI-картинкой
|
||||||
) : (
|
|
||||||
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -461,7 +695,6 @@ export default function ChannelView({ channel }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Планировщик */}
|
|
||||||
{showScheduler && (
|
{showScheduler && (
|
||||||
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
|
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
|
||||||
<label className="label text-xs">Время публикации (МСК)</label>
|
<label className="label text-xs">Время публикации (МСК)</label>
|
||||||
@@ -500,8 +733,47 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Правая колонка — превью */}
|
||||||
|
<div className="card p-4 sticky top-20">
|
||||||
|
<PostPreview
|
||||||
|
text={post}
|
||||||
|
imageUrl={image}
|
||||||
|
platform={channel.platform || 'telegram'}
|
||||||
|
channelName={channel.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* From URL modal */}
|
||||||
|
<FromUrlModal
|
||||||
|
open={showFromUrl}
|
||||||
|
channelId={channel.id}
|
||||||
|
onClose={() => setShowFromUrl(false)}
|
||||||
|
onApply={applyFromUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showPoll && (
|
||||||
|
<PollModal
|
||||||
|
channel={channel}
|
||||||
|
onClose={() => setShowPoll(false)}
|
||||||
|
onPublished={r => {
|
||||||
|
setShowPoll(false);
|
||||||
|
// Уведомление
|
||||||
|
if (r.scheduled) alert(`Опрос запланирован на ${new Date(r.scheduled_at).toLocaleString('ru-RU')}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo search modal */}
|
||||||
|
<PhotoSearchModal
|
||||||
|
open={showPhotoSearch}
|
||||||
|
onClose={() => setShowPhotoSearch(false)}
|
||||||
|
topic={topic}
|
||||||
|
post={post}
|
||||||
|
onPick={applyPhotoPick}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* История вариантов */}
|
{/* История вариантов */}
|
||||||
{variants.length > 0 && (
|
{variants.length > 0 && (
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
@@ -545,11 +817,11 @@ export default function ChannelView({ channel }) {
|
|||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
|
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
|
||||||
{p.image_url && (
|
{p.image_url && (
|
||||||
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" />
|
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" referrerPolicy="no-referrer" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
|
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||||
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
|
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
|
||||||
{statusLabels[p.status] || p.status}
|
{statusLabels[p.status] || p.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -562,13 +834,23 @@ export default function ChannelView({ channel }) {
|
|||||||
{!p.scheduled_at && !p.published_at && (
|
{!p.scheduled_at && !p.published_at && (
|
||||||
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
|
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
|
||||||
)}
|
)}
|
||||||
|
{p.image_credit?.domain && (
|
||||||
|
<span className="text-gray-500">📷 {p.image_credit.domain}</span>
|
||||||
|
)}
|
||||||
{p.error && (
|
{p.error && (
|
||||||
<span className="text-red-400 truncate">{p.error}</span>
|
<span className="text-red-400 truncate">{p.error}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPost(p.content); setImage(p.image_url); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }}
|
onClick={() => {
|
||||||
|
setPost(p.content);
|
||||||
|
setImage(p.image_url);
|
||||||
|
setImageCredit(p.image_credit || null);
|
||||||
|
setSavedPostId(p.id);
|
||||||
|
setTopic(p.topic || '');
|
||||||
|
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||||
|
}}
|
||||||
className="text-xs text-accent hover:underline shrink-0"
|
className="text-xs text-accent hover:underline shrink-0"
|
||||||
>
|
>
|
||||||
↑ открыть
|
↑ открыть
|
||||||
@@ -579,6 +861,7 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</> }
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,11 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Sparkles, LogOut } from 'lucide-react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Sparkles, LogOut, CalendarDays, Coins, FileText, Settings2 } from 'lucide-react';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
|
||||||
export default function Header({ user }) {
|
export default function Header({ user }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [credits, setCredits] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const refresh = () => fetch('/api/billing/balance').then(r => r.json())
|
||||||
|
.then(d => setCredits(d.isUnlimited ? '∞' : d.credits)).catch(() => {});
|
||||||
|
refresh();
|
||||||
|
window.addEventListener('credits-updated', refresh);
|
||||||
|
return () => window.removeEventListener('credits-updated', refresh);
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
@@ -17,7 +29,30 @@ export default function Header({ user }) {
|
|||||||
<Sparkles className="w-5 h-5 text-accent" />
|
<Sparkles className="w-5 h-5 text-accent" />
|
||||||
<span className="font-bold">ZeroPost</span>
|
<span className="font-bold">ZeroPost</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<nav className="hidden sm:flex items-center gap-1">
|
||||||
|
<Link href="/calendar" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
<span>Календарь</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/drafts" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Черновики</span>
|
||||||
|
</Link>
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link href="/system" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
<span>Админ</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Баланс кредитов */}
|
||||||
|
{credits !== null && (
|
||||||
|
<Link href="/billing" className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-accent/10 hover:bg-accent/20 transition-colors text-sm font-medium text-accent">
|
||||||
|
<Coins className="w-3.5 h-3.5" />
|
||||||
|
<span>{credits} кр</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span className="text-sm text-gray-500 hidden sm:inline mr-2">{user?.email}</span>
|
<span className="text-sm text-gray-500 hidden sm:inline mr-2">{user?.email}</span>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button onClick={logout} className="btn-ghost p-2" title="Выйти">
|
<button onClick={logout} className="btn-ghost p-2" title="Выйти">
|
||||||
@@ -28,3 +63,21 @@ export default function Header({ user }) {
|
|||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Публичный хедер для лендинга — отдельный экспорт
|
||||||
|
export function PublicHeader() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-border bg-surface sticky top-0 z-50">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
|
||||||
|
<Link href="/landing" className="flex items-center gap-2 font-bold">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent" />
|
||||||
|
<span>ZeroPost</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/login" className="btn-ghost text-sm px-3 py-1.5">Войти</Link>
|
||||||
|
<Link href="/register" className="btn-primary text-sm px-3 py-1.5">Начать бесплатно</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Loader2, Search, X, AlertCircle, ExternalLink, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
// Простая эвристика: если в тексте поста есть капитализованные ФИО (двусловные) —
|
||||||
|
// предлагаем их как стартовый query. Иначе берём первые слова темы.
|
||||||
|
function suggestQuery({ topic, post }) {
|
||||||
|
const text = `${topic || ''}\n${post || ''}`.trim();
|
||||||
|
if (!text) return '';
|
||||||
|
// Имена вида «Имя Фамилия» (две заглавные кириллицей или латиницей подряд)
|
||||||
|
const reName = /\b([А-ЯЁA-Z][а-яёa-z]{2,})\s+([А-ЯЁA-Z][а-яёa-z]{2,})/g;
|
||||||
|
const matches = [];
|
||||||
|
let m;
|
||||||
|
while ((m = reName.exec(text)) !== null) {
|
||||||
|
matches.push(`${m[1]} ${m[2]}`);
|
||||||
|
if (matches.length >= 3) break;
|
||||||
|
}
|
||||||
|
if (matches.length) return matches[0];
|
||||||
|
// Иначе — первые ~6 слов темы
|
||||||
|
return (topic || '').split(/\s+/).slice(0, 6).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhotoSearchModal({ open, onClose, topic, post, onPick }) {
|
||||||
|
const [profiles, setProfiles] = useState([]);
|
||||||
|
const [profile, setProfile] = useState('general');
|
||||||
|
const [quota, setQuota] = useState(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [meta, setMeta] = useState(null);
|
||||||
|
const [pickedIdx, setPickedIdx] = useState(null);
|
||||||
|
|
||||||
|
// Загружаем профили + квоту при первом открытии
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [profRes, quotaRes] = await Promise.all([
|
||||||
|
fetch('/api/photo-search/profiles'),
|
||||||
|
fetch('/api/photo-search/quota'),
|
||||||
|
]);
|
||||||
|
const profData = profRes.ok ? await profRes.json() : [];
|
||||||
|
const quotaData = quotaRes.ok ? await quotaRes.json() : null;
|
||||||
|
if (cancelled) return;
|
||||||
|
setProfiles(profData);
|
||||||
|
setQuota(quotaData);
|
||||||
|
// Дефолт-профиль: general (если есть), иначе первый
|
||||||
|
if (profData.length && !profData.find(p => p.slug === profile)) {
|
||||||
|
setProfile(profData[0].slug);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Подсказываем стартовый запрос
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (!query) setQuery(suggestQuery({ topic, post }));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (!query.trim() || searching) return;
|
||||||
|
setSearching(true);
|
||||||
|
setError('');
|
||||||
|
setItems([]);
|
||||||
|
setPickedIdx(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/photo-search/by-query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: query.trim(), profile, num: 6 }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (data.code === 'DAILY_LIMIT_EXCEEDED') {
|
||||||
|
throw new Error('Дневной лимит поиска фото исчерпан. Попробуй завтра или подними лимит в /system.');
|
||||||
|
}
|
||||||
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setItems(data.items || []);
|
||||||
|
setMeta({
|
||||||
|
total: data.total,
|
||||||
|
raw: data.raw_count,
|
||||||
|
filtered: data.filtered_count,
|
||||||
|
elapsedMs: data.elapsed_ms,
|
||||||
|
domains: data.domains || [],
|
||||||
|
});
|
||||||
|
if (data.quota) setQuota(data.quota);
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
setError('Ничего не нашлось в whitelisted доменах. Попробуй другой профиль или уточни запрос.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(idx) {
|
||||||
|
const item = items[idx];
|
||||||
|
if (!item) return;
|
||||||
|
setPickedIdx(idx);
|
||||||
|
onPick?.({
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
thumbUrl: item.thumbUrl,
|
||||||
|
credit: {
|
||||||
|
domain: item.sourceDomain || null,
|
||||||
|
sourceUrl: item.sourceUrl || null,
|
||||||
|
title: item.title || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start sm:items-center justify-center p-3 sm:p-6 overflow-y-auto"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card w-full max-w-3xl my-4"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between p-5 border-b border-border gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold flex items-center gap-2">
|
||||||
|
<Search className="w-4 h-4 text-accent" />
|
||||||
|
Найти фото
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Поиск по доменам из whitelist'а профиля. Используется Yandex Search API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="btn-ghost p-2" title="Закрыть">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="p-5 space-y-3 border-b border-border">
|
||||||
|
<div className="grid sm:grid-cols-[1fr_auto] gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Запрос</label>
|
||||||
|
<input
|
||||||
|
className="input text-sm"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') search(); }}
|
||||||
|
placeholder="Имя, событие, объект…"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Профиль</label>
|
||||||
|
<select
|
||||||
|
className="input text-sm"
|
||||||
|
value={profile}
|
||||||
|
onChange={e => setProfile(e.target.value)}
|
||||||
|
>
|
||||||
|
{profiles.length === 0 && <option value="general">general</option>}
|
||||||
|
{profiles.map(p => (
|
||||||
|
<option key={p.slug} value={p.slug}>
|
||||||
|
{p.name || p.slug} {p.domains?.length ? `(${p.domains.length})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{quota && (
|
||||||
|
<>Квота сегодня: <b>{quota.used}</b> / {quota.limit}{' '}
|
||||||
|
{quota.remaining === 0 && <span className="text-amber-500">(исчерпана)</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={search}
|
||||||
|
disabled={searching || !query.trim() || (quota?.remaining === 0)}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
|
{searching ? 'Ищу…' : 'Найти'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profiles.find(p => p.slug === profile)?.domains?.length > 0 && (
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
Whitelist:{' '}
|
||||||
|
{profiles.find(p => p.slug === profile).domains.slice(0, 8).join(', ')}
|
||||||
|
{profiles.find(p => p.slug === profile).domains.length > 8 && '…'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-5">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-amber-500 bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 mb-3">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div>{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta && !error && (
|
||||||
|
<div className="text-[11px] text-gray-500 mb-3">
|
||||||
|
Найдено всего: {meta.total} · после фильтра: {meta.filtered} · показано: {items.length} · {meta.elapsedMs} мс
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => pick(i)}
|
||||||
|
className={`group relative rounded-lg overflow-hidden border transition-all text-left ${
|
||||||
|
pickedIdx === i
|
||||||
|
? 'border-accent ring-2 ring-accent/40'
|
||||||
|
: 'border-border hover:border-accent/60'
|
||||||
|
}`}
|
||||||
|
title={it.title || it.sourceUrl}
|
||||||
|
>
|
||||||
|
<div className="aspect-square bg-surface2 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={it.thumbUrl || it.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={(e) => { e.currentTarget.style.opacity = '0.3'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-[10px] text-accent font-mono truncate">{it.sourceDomain}</div>
|
||||||
|
{it.title && (
|
||||||
|
<div className="text-[11px] text-gray-500 line-clamp-2 mt-0.5">{it.title}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-gray-500 mt-1">{it.width}×{it.height}</div>
|
||||||
|
</div>
|
||||||
|
{pickedIdx === i && (
|
||||||
|
<div className="absolute top-1.5 right-1.5 bg-accent text-white rounded-full p-1">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.sourceUrl && (
|
||||||
|
<a
|
||||||
|
href={it.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="absolute top-1.5 left-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full p-1"
|
||||||
|
title="Открыть источник"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && !error && !searching && (
|
||||||
|
<div className="text-sm text-gray-500 text-center py-10">
|
||||||
|
Введи запрос и нажми «Найти»
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-border flex items-center justify-end gap-2">
|
||||||
|
<button onClick={onClose} className="btn-ghost text-sm">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// **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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostTemplates — 7 кнопок-пресетов структуры поста.
|
||||||
|
* Props:
|
||||||
|
* onSelect(template) — вызывается с объектом { label, topic, structure }
|
||||||
|
* disabled — bool
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, Newspaper, Megaphone,
|
||||||
|
Briefcase, BookOpen, List, HelpCircle, User } from 'lucide-react';
|
||||||
|
|
||||||
|
const TEMPLATES = [
|
||||||
|
{
|
||||||
|
id: 'news',
|
||||||
|
label: 'Новость',
|
||||||
|
Icon: Newspaper,
|
||||||
|
hint: 'Факт → контекст → вывод',
|
||||||
|
topicHint: 'Новость или событие в нише',
|
||||||
|
structure: `[ЗАГОЛОВОК — суть в одной строке]
|
||||||
|
|
||||||
|
[2–3 предложения: что произошло, ключевые цифры]
|
||||||
|
|
||||||
|
[Почему это важно для читателя]
|
||||||
|
|
||||||
|
[Личный вывод или вопрос к аудитории]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'announce',
|
||||||
|
label: 'Анонс',
|
||||||
|
Icon: Megaphone,
|
||||||
|
hint: 'Интрига → суть → CTA',
|
||||||
|
topicHint: 'Анонс события, релиза, запуска',
|
||||||
|
structure: `[Интригующий первый абзац — зачем читать дальше]
|
||||||
|
|
||||||
|
📅 [Дата и что именно происходит]
|
||||||
|
|
||||||
|
✅ [3–4 буллита: что будет / что получит читатель]
|
||||||
|
|
||||||
|
👉 [Призыв к действию со ссылкой]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case',
|
||||||
|
label: 'Кейс',
|
||||||
|
Icon: Briefcase,
|
||||||
|
hint: 'Ситуация → решение → результат',
|
||||||
|
topicHint: 'Реальный пример из практики',
|
||||||
|
structure: `[Задача: что было за проблема]
|
||||||
|
|
||||||
|
[Что попробовал, что не сработало]
|
||||||
|
|
||||||
|
[Что сработало — конкретные шаги]
|
||||||
|
|
||||||
|
📊 Результат: [цифры или конкретный итог]
|
||||||
|
|
||||||
|
[Вывод — что можно повторить]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'longread',
|
||||||
|
label: 'Лонгрид',
|
||||||
|
Icon: BookOpen,
|
||||||
|
hint: 'Глубокий разбор темы',
|
||||||
|
topicHint: 'Тема для развёрнутого объяснения',
|
||||||
|
structure: `[Провокационный или неожиданный тезис]
|
||||||
|
|
||||||
|
[Почему стандартный взгляд ошибается]
|
||||||
|
|
||||||
|
[Аргумент 1 + пример]
|
||||||
|
|
||||||
|
[Аргумент 2 + пример]
|
||||||
|
|
||||||
|
[Аргумент 3 + пример]
|
||||||
|
|
||||||
|
[Заключение: к чему приходим]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list',
|
||||||
|
label: 'Подборка',
|
||||||
|
Icon: List,
|
||||||
|
hint: 'N полезных штук',
|
||||||
|
topicHint: 'Список инструментов, советов, ресурсов',
|
||||||
|
structure: `[Почему эта подборка полезна]
|
||||||
|
|
||||||
|
1. [Название] — [1 предложение почему]
|
||||||
|
2. [Название] — [1 предложение почему]
|
||||||
|
3. [Название] — [1 предложение почему]
|
||||||
|
4. [Название] — [1 предложение почему]
|
||||||
|
5. [Название] — [1 предложение почему]
|
||||||
|
|
||||||
|
[Итог или личная рекомендация #1]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'poll',
|
||||||
|
label: 'Опрос-разбор',
|
||||||
|
Icon: HelpCircle,
|
||||||
|
hint: 'Вопрос → варианты → разбор',
|
||||||
|
topicHint: 'Дискуссионный вопрос для аудитории',
|
||||||
|
structure: `[Провокационный вопрос к читателю]
|
||||||
|
|
||||||
|
Как бы ты поступил?
|
||||||
|
|
||||||
|
А) [Вариант 1]
|
||||||
|
Б) [Вариант 2]
|
||||||
|
В) [Вариант 3]
|
||||||
|
|
||||||
|
[Мой ответ и почему именно так]
|
||||||
|
|
||||||
|
[Приглашение высказаться в комментариях]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal',
|
||||||
|
label: 'Личное',
|
||||||
|
Icon: User,
|
||||||
|
hint: 'История → урок → применение',
|
||||||
|
topicHint: 'Личный опыт или наблюдение',
|
||||||
|
structure: `[Конкретная ситуация из жизни — детали, дата, место]
|
||||||
|
|
||||||
|
[Что почувствовал / что понял в тот момент]
|
||||||
|
|
||||||
|
[Урок, который из этого вынес]
|
||||||
|
|
||||||
|
[Как это меняет то, что я делаю сейчас]
|
||||||
|
|
||||||
|
[Вопрос читателю — было ли у него похожее?]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PostTemplates({ onSelect, disabled }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
function pick(tpl) {
|
||||||
|
onSelect({ label: tpl.label, topicHint: tpl.topicHint, structure: tpl.structure });
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{open ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
Шаблоны
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-6 z-20 w-72 rounded-xl border border-border bg-surface shadow-xl p-2">
|
||||||
|
<div className="text-xs font-semibold text-text-mute uppercase tracking-wide px-2 py-1 mb-1">
|
||||||
|
Выбери структуру поста
|
||||||
|
</div>
|
||||||
|
{TEMPLATES.map(tpl => (
|
||||||
|
<button
|
||||||
|
key={tpl.id}
|
||||||
|
onClick={() => pick(tpl)}
|
||||||
|
className="w-full flex items-start gap-2.5 px-2.5 py-2 rounded-lg hover:bg-surface2 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<tpl.Icon className="w-4 h-4 text-accent mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-text">{tpl.label}</div>
|
||||||
|
<div className="text-xs text-text-mute">{tpl.hint}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
Темы используются при автоматической генерации постов. При запасе <5 — AI пополняет автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 > 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Engine client — единая точка вызовов к zeropost-engine
|
* Engine client — единая точка вызовов к zeropost-engine
|
||||||
*/
|
*/
|
||||||
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3040';
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
|
||||||
|
|
||||||
async function call(path, options = {}) {
|
async function call(path, options = {}) {
|
||||||
@@ -21,7 +21,10 @@ async function call(path, options = {}) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
throw new Error(err.error || `Engine ${res.status}`);
|
const e = new Error(err.error || `Engine ${res.status}`);
|
||||||
|
e.status = res.status;
|
||||||
|
e.code = err.code;
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -52,4 +55,65 @@ export const engine = {
|
|||||||
updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }),
|
updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }),
|
||||||
deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }),
|
deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }),
|
||||||
publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }),
|
publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }),
|
||||||
|
|
||||||
|
// Photo search
|
||||||
|
photoSearchProfiles: () => call('/api/photo-search/profiles'),
|
||||||
|
photoSearchQuota: () => call('/api/photo-search/quota'),
|
||||||
|
photoSearchByQuery: (data) => call('/api/photo-search/by-query', { method: 'POST', body: data }),
|
||||||
|
|
||||||
|
// Settings (admin)
|
||||||
|
listSettings: (category) => {
|
||||||
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||||
|
return call(`/api/settings/admin${qs}`);
|
||||||
|
},
|
||||||
|
updateSetting: (key, value) => call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }),
|
||||||
|
invalidateSettingsCache: () => call('/api/settings/admin/invalidate', { method: 'POST' }),
|
||||||
|
|
||||||
|
// AI usage (admin)
|
||||||
|
usageSummary: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/usage/summary${qs ? '?' + qs : ''}`);
|
||||||
|
},
|
||||||
|
usageRecent: (limit = 20) => call(`/api/usage/recent?limit=${limit}`),
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
getBillingBalance: (userId) => call('/api/billing/balance', { userId }),
|
||||||
|
getBillingPlans: () => fetch('/api/billing/plans', { cache: 'no-store' }).then(r => r.json()),
|
||||||
|
getTransactions: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/billing/transactions?${qs}`);
|
||||||
|
},
|
||||||
|
adminCreditUser: (data) => call('/api/billing/admin/credit', { method: 'POST', body: data }),
|
||||||
|
adminGetBalances: () => call('/api/billing/admin/users'),
|
||||||
|
|
||||||
|
// Editor notes
|
||||||
|
listNotes: () => call('/api/notes?limit=100'),
|
||||||
|
createNote: (data) => call('/api/notes', { method: 'POST', body: data }),
|
||||||
|
updateNote: (id, data) => call(`/api/notes/${id}`, { method: 'PATCH', body: data }),
|
||||||
|
deleteNote: (id) => call(`/api/notes/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
getCalendar: (userId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/calendar${qs ? '?' + qs : ''}`, { userId });
|
||||||
|
},
|
||||||
|
// Metrics
|
||||||
|
getChannelMetrics: (channelId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/metrics/channel/${channelId}${qs ? '?' + qs : ''}`);
|
||||||
|
},
|
||||||
|
getBestTime: (channelId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/metrics/best-time/${channelId}${qs ? '?' + qs : ''}`);
|
||||||
|
},
|
||||||
|
getUserPostMetrics: (userId, channelId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/metrics/user-posts/${channelId}${qs ? '?' + qs : ''}`, { userId });
|
||||||
|
},
|
||||||
|
collectMetrics: () => call('/api/metrics/collect', { method: 'POST' }),
|
||||||
|
generateFromUrl: (userId, data) => call('/api/generate/from-url', { userId, method: 'POST', body: data }),
|
||||||
|
|
||||||
|
updateUserPostSchedule: (userId, id, scheduledAt) =>
|
||||||
|
call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: { scheduled_at: scheduledAt } }),
|
||||||
};
|
};
|
||||||
|
// Добавляем в конец файла перед module.exports или в общий объект
|
||||||
|
|||||||
+7
-1
@@ -20,5 +20,11 @@ export async function getSession() {
|
|||||||
export async function requireUser() {
|
export async function requireUser() {
|
||||||
const s = await getSession();
|
const s = await getSession();
|
||||||
if (!s.userId) return null;
|
if (!s.userId) return null;
|
||||||
return { id: s.userId, email: s.email, name: s.name };
|
return { id: s.userId, email: s.email, name: s.name, isAdmin: !!s.isAdmin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const u = await requireUser();
|
||||||
|
if (!u || !u.isAdmin) return null;
|
||||||
|
return u;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user