diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..76535fe --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/app/api/admin/autogen/[category]/route.js b/app/api/admin/autogen/[category]/route.js new file mode 100644 index 0000000..52bdcaa --- /dev/null +++ b/app/api/admin/autogen/[category]/route.js @@ -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()); +} diff --git a/app/api/admin/autogen/[category]/run/route.js b/app/api/admin/autogen/[category]/run/route.js new file mode 100644 index 0000000..ed699f5 --- /dev/null +++ b/app/api/admin/autogen/[category]/run/route.js @@ -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()); +} diff --git a/app/api/admin/autogen/queue/[id]/route.js b/app/api/admin/autogen/queue/[id]/route.js new file mode 100644 index 0000000..a8bb328 --- /dev/null +++ b/app/api/admin/autogen/queue/[id]/route.js @@ -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()); +} diff --git a/app/api/admin/autogen/route.js b/app/api/admin/autogen/route.js new file mode 100644 index 0000000..7650453 --- /dev/null +++ b/app/api/admin/autogen/route.js @@ -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 }); +} diff --git a/app/api/admin/blog-topics/[id]/route.js b/app/api/admin/blog-topics/[id]/route.js new file mode 100644 index 0000000..94d12a3 --- /dev/null +++ b/app/api/admin/blog-topics/[id]/route.js @@ -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()); +} diff --git a/app/api/admin/blog-topics/generate/route.js b/app/api/admin/blog-topics/generate/route.js new file mode 100644 index 0000000..b25e58c --- /dev/null +++ b/app/api/admin/blog-topics/generate/route.js @@ -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()); +} diff --git a/app/api/admin/blog-topics/route.js b/app/api/admin/blog-topics/route.js new file mode 100644 index 0000000..3d850e8 --- /dev/null +++ b/app/api/admin/blog-topics/route.js @@ -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 }); +} diff --git a/app/api/admin/credit-costs/[operation]/route.js b/app/api/admin/credit-costs/[operation]/route.js new file mode 100644 index 0000000..fff0c3b --- /dev/null +++ b/app/api/admin/credit-costs/[operation]/route.js @@ -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()); +} diff --git a/app/api/admin/credit/route.js b/app/api/admin/credit/route.js new file mode 100644 index 0000000..e5a25f8 --- /dev/null +++ b/app/api/admin/credit/route.js @@ -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()); +} diff --git a/app/api/admin/dashboard/route.js b/app/api/admin/dashboard/route.js new file mode 100644 index 0000000..463aa35 --- /dev/null +++ b/app/api/admin/dashboard/route.js @@ -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()); +} diff --git a/app/api/admin/email/test/route.js b/app/api/admin/email/test/route.js new file mode 100644 index 0000000..49c6fee --- /dev/null +++ b/app/api/admin/email/test/route.js @@ -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 }); +} diff --git a/app/api/admin/logs/route.js b/app/api/admin/logs/route.js new file mode 100644 index 0000000..5f85c55 --- /dev/null +++ b/app/api/admin/logs/route.js @@ -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()); +} diff --git a/app/api/admin/plans/[id]/route.js b/app/api/admin/plans/[id]/route.js new file mode 100644 index 0000000..439ec01 --- /dev/null +++ b/app/api/admin/plans/[id]/route.js @@ -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()); +} diff --git a/app/api/admin/promos/[id]/route.js b/app/api/admin/promos/[id]/route.js new file mode 100644 index 0000000..0693d61 --- /dev/null +++ b/app/api/admin/promos/[id]/route.js @@ -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()); +} diff --git a/app/api/admin/promos/route.js b/app/api/admin/promos/route.js new file mode 100644 index 0000000..da48543 --- /dev/null +++ b/app/api/admin/promos/route.js @@ -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 }); +} diff --git a/app/api/admin/queue/[id]/retry/route.js b/app/api/admin/queue/[id]/retry/route.js new file mode 100644 index 0000000..cdad282 --- /dev/null +++ b/app/api/admin/queue/[id]/retry/route.js @@ -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()); +} diff --git a/app/api/admin/queue/route.js b/app/api/admin/queue/route.js new file mode 100644 index 0000000..d1db5c4 --- /dev/null +++ b/app/api/admin/queue/route.js @@ -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()); +} diff --git a/app/api/admin/settings/[key]/route.js b/app/api/admin/settings/[key]/route.js new file mode 100644 index 0000000..ec2a50a --- /dev/null +++ b/app/api/admin/settings/[key]/route.js @@ -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 }); + } +} diff --git a/app/api/admin/settings/route.js b/app/api/admin/settings/route.js new file mode 100644 index 0000000..3f27a61 --- /dev/null +++ b/app/api/admin/settings/route.js @@ -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 }); + } +} diff --git a/app/api/admin/usage/summary/route.js b/app/api/admin/usage/summary/route.js new file mode 100644 index 0000000..bf18558 --- /dev/null +++ b/app/api/admin/usage/summary/route.js @@ -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 }); + } +} diff --git a/app/api/admin/users/[id]/route.js b/app/api/admin/users/[id]/route.js new file mode 100644 index 0000000..d157752 --- /dev/null +++ b/app/api/admin/users/[id]/route.js @@ -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()); +} diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js index fff57fd..50c898c 100644 --- a/app/api/auth/login/route.js +++ b/app/api/auth/login/route.js @@ -16,19 +16,33 @@ export async function POST(req) { } const hash = await bcrypt.hash(password, 10); const { rows } = await q( - `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`, + `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`, [email, hash] ); const user = rows[0]; const s = await getSession(); s.userId = user.id; s.email = user.email; + s.isAdmin = !!user.is_admin; await s.save(); - return NextResponse.json({ ok: true, user }); + + // Инициализируем баланс нового пользователя (Free план, 50 кредитов) + try { + const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; + const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + await fetch(`${ENGINE_URL}/api/billing/balance`, { + headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) }, + }); + } catch {} + + return NextResponse.json({ ok: true, user, isNew: true }); } // login - const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]); + const { rows } = await q( + `SELECT id,email,password,name,is_admin FROM users WHERE email=$1`, + [email] + ); if (!rows.length) { return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 }); } @@ -41,6 +55,10 @@ export async function POST(req) { s.userId = user.id; s.email = user.email; s.name = user.name; + s.isAdmin = !!user.is_admin; await s.save(); - return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } }); + return NextResponse.json({ + ok: true, + user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin }, + }); } diff --git a/app/api/billing/admin/credit/route.js b/app/api/billing/admin/credit/route.js new file mode 100644 index 0000000..4509331 --- /dev/null +++ b/app/api/billing/admin/credit/route.js @@ -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 }); } +} diff --git a/app/api/billing/admin/users/route.js b/app/api/billing/admin/users/route.js new file mode 100644 index 0000000..74e0f2c --- /dev/null +++ b/app/api/billing/admin/users/route.js @@ -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 }); } +} diff --git a/app/api/billing/apply-promo/route.js b/app/api/billing/apply-promo/route.js new file mode 100644 index 0000000..5977f0d --- /dev/null +++ b/app/api/billing/apply-promo/route.js @@ -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 }); +} diff --git a/app/api/billing/balance/route.js b/app/api/billing/balance/route.js new file mode 100644 index 0000000..83cefe0 --- /dev/null +++ b/app/api/billing/balance/route.js @@ -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 }); + } +} diff --git a/app/api/billing/checkout/route.js b/app/api/billing/checkout/route.js new file mode 100644 index 0000000..1d650f2 --- /dev/null +++ b/app/api/billing/checkout/route.js @@ -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 }); } +} diff --git a/app/api/billing/plans/route.js b/app/api/billing/plans/route.js new file mode 100644 index 0000000..df032ed --- /dev/null +++ b/app/api/billing/plans/route.js @@ -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 }); + } +} diff --git a/app/api/billing/transactions/route.js b/app/api/billing/transactions/route.js new file mode 100644 index 0000000..20cce0d --- /dev/null +++ b/app/api/billing/transactions/route.js @@ -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 }); + } +} diff --git a/app/api/calendar/route.js b/app/api/calendar/route.js new file mode 100644 index 0000000..2c65f5c --- /dev/null +++ b/app/api/calendar/route.js @@ -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 }); + } +} diff --git a/app/api/channels/[id]/drafts/generate/route.js b/app/api/channels/[id]/drafts/generate/route.js new file mode 100644 index 0000000..2e94ef8 --- /dev/null +++ b/app/api/channels/[id]/drafts/generate/route.js @@ -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()); +} diff --git a/app/api/channels/[id]/poll/route.js b/app/api/channels/[id]/poll/route.js new file mode 100644 index 0000000..299cdd2 --- /dev/null +++ b/app/api/channels/[id]/poll/route.js @@ -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 }); + } +} diff --git a/app/api/channels/route.js b/app/api/channels/route.js index be2288d..b12c751 100644 --- a/app/api/channels/route.js +++ b/app/api/channels/route.js @@ -21,6 +21,7 @@ export async function POST(req) { const channel = await engine.createChannel(user.id, body); return NextResponse.json(channel); } catch (err) { - return NextResponse.json({ error: err.message }, { status: 500 }); + const status = err.status === 402 ? 402 : 500; + return NextResponse.json({ error: err.message, code: err.code }, { status }); } } diff --git a/app/api/drafts/[id]/approve/route.js b/app/api/drafts/[id]/approve/route.js new file mode 100644 index 0000000..07d53eb --- /dev/null +++ b/app/api/drafts/[id]/approve/route.js @@ -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()); +} diff --git a/app/api/drafts/[id]/reject/route.js b/app/api/drafts/[id]/reject/route.js new file mode 100644 index 0000000..f65bd20 --- /dev/null +++ b/app/api/drafts/[id]/reject/route.js @@ -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()); +} diff --git a/app/api/drafts/[id]/route.js b/app/api/drafts/[id]/route.js new file mode 100644 index 0000000..ad5afd1 --- /dev/null +++ b/app/api/drafts/[id]/route.js @@ -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()); +} diff --git a/app/api/drafts/route.js b/app/api/drafts/route.js new file mode 100644 index 0000000..0db94d8 --- /dev/null +++ b/app/api/drafts/route.js @@ -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()); +} diff --git a/app/api/generate/from-url/route.js b/app/api/generate/from-url/route.js new file mode 100644 index 0000000..25dc834 --- /dev/null +++ b/app/api/generate/from-url/route.js @@ -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 }); + } +} diff --git a/app/api/generate/hashtags/route.js b/app/api/generate/hashtags/route.js new file mode 100644 index 0000000..bc8b260 --- /dev/null +++ b/app/api/generate/hashtags/route.js @@ -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 }); + } +} diff --git a/app/api/inbox/channel/[channelId]/route.js b/app/api/inbox/channel/[channelId]/route.js new file mode 100644 index 0000000..3fd0eaf --- /dev/null +++ b/app/api/inbox/channel/[channelId]/route.js @@ -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()); +} diff --git a/app/api/inbox/channel/[channelId]/setup-webhook/route.js b/app/api/inbox/channel/[channelId]/setup-webhook/route.js new file mode 100644 index 0000000..09b4b2f --- /dev/null +++ b/app/api/inbox/channel/[channelId]/setup-webhook/route.js @@ -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()); +} diff --git a/app/api/inbox/message/[id]/reply/route.js b/app/api/inbox/message/[id]/reply/route.js new file mode 100644 index 0000000..260870d --- /dev/null +++ b/app/api/inbox/message/[id]/reply/route.js @@ -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()); +} diff --git a/app/api/inbox/message/[id]/status/route.js b/app/api/inbox/message/[id]/status/route.js new file mode 100644 index 0000000..dd72c73 --- /dev/null +++ b/app/api/inbox/message/[id]/status/route.js @@ -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()); +} diff --git a/app/api/metrics/best-time/[channelId]/route.js b/app/api/metrics/best-time/[channelId]/route.js new file mode 100644 index 0000000..a7b2e05 --- /dev/null +++ b/app/api/metrics/best-time/[channelId]/route.js @@ -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 }); + } +} diff --git a/app/api/metrics/channel/[channelId]/route.js b/app/api/metrics/channel/[channelId]/route.js new file mode 100644 index 0000000..7b920cb --- /dev/null +++ b/app/api/metrics/channel/[channelId]/route.js @@ -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 }); + } +} diff --git a/app/api/notes/[id]/route.js b/app/api/notes/[id]/route.js new file mode 100644 index 0000000..cfe0109 --- /dev/null +++ b/app/api/notes/[id]/route.js @@ -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 }); + } +} diff --git a/app/api/notes/route.js b/app/api/notes/route.js new file mode 100644 index 0000000..2252211 --- /dev/null +++ b/app/api/notes/route.js @@ -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 }); + } +} diff --git a/app/api/photo-search/by-query/route.js b/app/api/photo-search/by-query/route.js new file mode 100644 index 0000000..4121c36 --- /dev/null +++ b/app/api/photo-search/by-query/route.js @@ -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 } + ); + } +} diff --git a/app/api/photo-search/profiles/route.js b/app/api/photo-search/profiles/route.js new file mode 100644 index 0000000..7d6dc4a --- /dev/null +++ b/app/api/photo-search/profiles/route.js @@ -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 }); + } +} diff --git a/app/api/photo-search/quota/route.js b/app/api/photo-search/quota/route.js new file mode 100644 index 0000000..e108bcb --- /dev/null +++ b/app/api/photo-search/quota/route.js @@ -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 }); + } +} diff --git a/app/api/topics-bank/[channelId]/add/route.js b/app/api/topics-bank/[channelId]/add/route.js new file mode 100644 index 0000000..87cf56b --- /dev/null +++ b/app/api/topics-bank/[channelId]/add/route.js @@ -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()); +} diff --git a/app/api/topics-bank/[channelId]/refill/route.js b/app/api/topics-bank/[channelId]/refill/route.js new file mode 100644 index 0000000..9f71d38 --- /dev/null +++ b/app/api/topics-bank/[channelId]/refill/route.js @@ -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()); +} diff --git a/app/api/topics-bank/[channelId]/route.js b/app/api/topics-bank/[channelId]/route.js new file mode 100644 index 0000000..f5167f1 --- /dev/null +++ b/app/api/topics-bank/[channelId]/route.js @@ -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()); +} diff --git a/app/api/topics-bank/item/[id]/route.js b/app/api/topics-bank/item/[id]/route.js new file mode 100644 index 0000000..c022f07 --- /dev/null +++ b/app/api/topics-bank/item/[id]/route.js @@ -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()); +} diff --git a/app/api/usage/summary/route.js b/app/api/usage/summary/route.js new file mode 100644 index 0000000..3cd3bac --- /dev/null +++ b/app/api/usage/summary/route.js @@ -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 }); + } +} diff --git a/app/billing/page.js b/app/billing/page.js new file mode 100644 index 0000000..8bf2d5f --- /dev/null +++ b/app/billing/page.js @@ -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 ( +
+ +
+

+ Баланс и кредиты +

+ +
+ + {/* Баланс */} + {balance && ( +
+
+
+ {balance.isUnlimited ? '∞' : balance.credits} +
+
кредитов осталось
+
+
+
+ {balance.planName} +
+
текущий тариф
+
+
+
+ {balance.resetAt ? new Date(balance.resetAt).toLocaleDateString('ru-RU') : '—'} +
+
сброс кредитов
+
+
+ )} + + {/* CTA апгрейд */} + {balance?.plan === 'free' && ( +
+
+
Хотите больше кредитов?
+
Starter — 500 кредитов за ₽490/мес
+
+ + Тарифы + +
+ )} + + {/* Промокод */} + load(0)} /> + + {/* Стоимость операций */} +
+
Стоимость операций
+
+ {[ + { label: 'Картинка', credits: 5, icon: '🖼' }, + { label: 'Пост', credits: 2, icon: '✍️' }, + { label: 'Статья', credits: 5, icon: '📝' }, + { label: 'Публикация', credits: 0, icon: '📤' }, + ].map(op => ( +
+
{op.icon}
+
{op.label}
+
+ {op.credits === 0 ? 'бесплатно' : `${op.credits} кр`} +
+
+ ))} +
+
+ + {/* История транзакций */} +

История

+ {loading &&
} + {!loading && ( +
+ {txs.length === 0 && ( +
Транзакций пока нет
+ )} + {txs.map(tx => { + const meta = TYPE_LABELS[tx.type] || { label: tx.type, sign: tx.amount > 0 ? '+' : '-', color: 'text-gray-400' }; + return ( +
+
+
{tx.description || meta.label}
+
{fmtDate(tx.created_at)}
+
+
+
+ {meta.sign}{Math.abs(tx.amount)} кр +
+
= {tx.balance_after === -1 ? '∞' : tx.balance_after} кр
+
+
+ ); + })} +
+ )} + + {/* Пагинация */} + {total > PER_PAGE && ( +
+ + {page+1} / {Math.ceil(total/PER_PAGE)} + +
+ )} +
+ ); +} + +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 ( +
+ {!show ? ( + + ) : ( +
+
+ 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} + /> + + +
+ {msg &&

{msg}

} +
+ )} +
+ ); +} diff --git a/app/calendar/page.js b/app/calendar/page.js new file mode 100644 index 0000000..af10414 --- /dev/null +++ b/app/calendar/page.js @@ -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 ( + <> +
+
+
+
+

Календарь публикаций

+

+ Планируй и отслеживай выход постов по всем каналам +

+
+
+ +
+ + ); +} diff --git a/app/channels/new/page.js b/app/channels/new/page.js index 481fc3f..41d89b1 100644 --- a/app/channels/new/page.js +++ b/app/channels/new/page.js @@ -49,7 +49,8 @@ export default function NewChannelPage() { const [name, setName] = useState(''); const [niche, setNiche] = useState(''); const [audience, setAudience] = useState(''); - const [goal, setGoal] = useState('educational'); + const [goals, setGoals] = useState(['educational']); // multi-select, отправляем как CSV + const [customGoal, setCustomGoal] = useState(''); // поле для своей цели const [language, setLanguage] = useState('ru'); // Шаг 2 — стиль @@ -70,7 +71,7 @@ export default function NewChannelPage() { setBusy(true); setError(''); const data = { - name, niche, audience, goal, language, region: 'ru', + name, niche, audience, goal: goals.join(','), language, region: 'ru', style: { tone, formality, humor, post_length: postLength, @@ -88,7 +89,16 @@ export default function NewChannelPage() { }); const json = await res.json(); setBusy(false); - if (!res.ok) { setError(json.error || 'Ошибка'); return; } + if (!res.ok) { + if (json.code === 'CHANNEL_LIMIT_REACHED') { + setError(`${json.error} → `); + // Перенаправим на страницу тарифов через 2 сек + setTimeout(() => router.push('/plans'), 2000); + } else { + setError(json.error || 'Ошибка'); + } + return; + } router.push(`/channels/${json.id}`); } @@ -150,22 +160,67 @@ export default function NewChannelPage() { />
- +
- {GOALS.map(g => ( - - ))} + {GOALS.map(g => { + const on = goals.includes(g.v); + return ( + + ); + })}
+ {/* Своя цель */} +
+ 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(''); + } + }} + /> + +
+ {/* Выбранные кастомные цели — чипы */} + {goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && ( +
+ {goals.filter(g => !GOALS.find(x => x.v === g)).map(g => ( + + {g} + + + ))} +
+ )}
diff --git a/app/drafts/page.js b/app/drafts/page.js new file mode 100644 index 0000000..a7b6a87 --- /dev/null +++ b/app/drafts/page.js @@ -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 ( +
+ +
+
+

+ Черновики +

+

+ {tab === 'pending' && total > 0 + ? `${total} ${total === 1 ? 'пост ждёт' : 'поста ждут'} одобрения` + : 'Авто-генерированные и пакетные посты на проверку'} +

+
+ +
+ + {/* Табы */} +
+ {STATUS_TABS.map(t => ( + + ))} +
+ + {loading &&
} + + {!loading && drafts.length === 0 && ( +
+ +
+ {tab === 'pending' + ? <>Нет черновиков. Включите авто-генерацию в настройках канала или сгенерируйте вручную. + : 'Нет записей'} +
+
+ )} + +
+ {drafts.map(draft => ( +
+ {/* Header */} +
+
+ {draft.channel_name} + {draft.platform && {draft.platform}} +
+
+ + {timeAgo(draft.created_at)} +
+
+ + {/* Тема */} + {draft.topic && ( +
+ 💡 {draft.topic} +
+ )} + + {/* Текст */} + {editing === draft.id ? ( +
+