From c7e9196370b746c7f515d0ba995a4e1ce613d497 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 20:55:34 +0300 Subject: [PATCH] feat(admin/articles): delete button + 'scheduled' badge for future drip slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArticleRowActions (client component): edit / view / delete actions. Delete: confirm dialog → /admin/api/articles/:id DELETE → reload page. StatusBadge: shows blue 'выйдет ДД.ММ ЧЧ:ММ' badge when published_at is in the future (article scheduled for a drip slot), green 'Опубликована' when actually live, amber 'Черновик' otherwise. Proxy: app/admin/api/articles/[id]/route.js → engine /api/articles/:id. --- app/admin/(protected)/articles/page.js | 29 ++------ app/admin/api/articles/[id]/route.js | 43 ++++++++---- components/admin/ArticleRowActions.js | 95 ++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 37 deletions(-) create mode 100644 components/admin/ArticleRowActions.js diff --git a/app/admin/(protected)/articles/page.js b/app/admin/(protected)/articles/page.js index 5959729..e923273 100644 --- a/app/admin/(protected)/articles/page.js +++ b/app/admin/(protected)/articles/page.js @@ -1,6 +1,7 @@ import Link from 'next/link'; import { adminListArticles } from '@/lib/engine'; -import { Plus, Pencil, Eye } from 'lucide-react'; +import { Plus } from 'lucide-react'; +import ArticleRowActions, { StatusBadge } from '@/components/admin/ArticleRowActions'; export const dynamic = 'force-dynamic'; export const metadata = { title: 'Статьи' }; @@ -55,35 +56,13 @@ export default async function AdminArticlesPage() { - - {a.status === 'published' ? 'Опубликована' : 'Черновик'} - + {a.published_at ? new Date(a.published_at).toLocaleDateString('ru-RU') : '—'} -
- - - - - - -
+ ))} diff --git a/app/admin/api/articles/[id]/route.js b/app/admin/api/articles/[id]/route.js index 17119a0..ad7ae43 100644 --- a/app/admin/api/articles/[id]/route.js +++ b/app/admin/api/articles/[id]/route.js @@ -1,18 +1,37 @@ +/** + * Proxy для /admin/api/articles/:id → engine /api/articles/:id + * Используется для DELETE из admin UI. + */ import { NextResponse } from 'next/server'; import { checkAdminAuth } from '@/lib/adminAuth'; -import { adminUpdateArticle, adminDeleteArticle } from '@/lib/engine'; -export async function PATCH(req, { params }) { - if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); +const E = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; +const S = process.env.ENGINE_SECRET || 'zeropost_internal_2026'; + +async function proxy(req, { params }) { + if (!(await checkAdminAuth())) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } const { id } = await params; - const body = await req.json(); - const result = await adminUpdateArticle(id, body); - return NextResponse.json(result); + const url = `${E}/api/articles/${id}`; + + const headers = { 'x-internal-secret': S, 'x-user-id': '1' }; + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + body = (await req.text()) || undefined; + } + + try { + const res = await fetch(url, { method: req.method, headers, body, cache: 'no-store' }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 502 }); + } } -export async function DELETE(req, { params }) { - if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const { id } = await params; - const result = await adminDeleteArticle(id); - return NextResponse.json(result); -} +export const GET = proxy; +export const PATCH = proxy; +export const PUT = proxy; +export const DELETE = proxy; diff --git a/components/admin/ArticleRowActions.js b/components/admin/ArticleRowActions.js new file mode 100644 index 0000000..8fb384f --- /dev/null +++ b/components/admin/ArticleRowActions.js @@ -0,0 +1,95 @@ +'use client'; +import Link from 'next/link'; +import { useState } from 'react'; +import { Pencil, Eye, Trash2, Loader2, Clock } from 'lucide-react'; + +/** + * Действия в строке статьи: редактировать, открыть, удалить. + * Удаление через confirm() + POST proxy → engine DELETE /api/articles/:id. + */ +export default function ArticleRowActions({ article }) { + const [busy, setBusy] = useState(false); + const [gone, setGone] = useState(false); + + async function handleDelete(e) { + e.preventDefault(); + if (!confirm(`Удалить «${article.title.slice(0, 60)}»? Это действие нельзя отменить.`)) return; + setBusy(true); + try { + const r = await fetch(`/admin/api/articles/${article.id}`, { method: 'DELETE' }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + alert('Ошибка: ' + (d.error || r.status)); + setBusy(false); + return; + } + setGone(true); + // Refresh страницы, чтобы серверный render обновил список + setTimeout(() => window.location.reload(), 400); + } catch (err) { + alert('Ошибка: ' + err.message); + setBusy(false); + } + } + + if (gone) { + return ( +
+ удалена +
+ ); + } + + return ( +
+ + + + + + + +
+ ); +} + +/** + * Для статуса показываем дополнительно «выйдет в HH:MM» если published_at в будущем. + */ +export function StatusBadge({ status, published_at }) { + const future = status === 'published' && published_at && new Date(published_at) > new Date(); + if (future) { + const d = new Date(published_at); + const label = d.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + return ( + + {label} + + ); + } + return ( + + {status === 'published' ? 'Опубликована' : 'Черновик'} + + ); +}