feat(admin/articles): delete button + 'scheduled' badge for future drip slots
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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { adminListArticles } from '@/lib/engine';
|
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 dynamic = 'force-dynamic';
|
||||||
export const metadata = { title: 'Статьи' };
|
export const metadata = { title: 'Статьи' };
|
||||||
@@ -55,35 +56,13 @@ export default async function AdminArticlesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3.5">
|
<td className="px-3 py-3.5">
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
<StatusBadge status={a.status} published_at={a.published_at} />
|
||||||
a.status === 'published'
|
|
||||||
? 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400'
|
|
||||||
: 'bg-amber-50 dark:bg-amber-950 text-amber-600 dark:text-amber-400'
|
|
||||||
}`}>
|
|
||||||
{a.status === 'published' ? 'Опубликована' : 'Черновик'}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3.5 text-xs text-neutral-400 hidden lg:table-cell">
|
<td className="px-3 py-3.5 text-xs text-neutral-400 hidden lg:table-cell">
|
||||||
{a.published_at ? new Date(a.published_at).toLocaleDateString('ru-RU') : '—'}
|
{a.published_at ? new Date(a.published_at).toLocaleDateString('ru-RU') : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3.5">
|
<td className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<ArticleRowActions article={a} />
|
||||||
<Link
|
|
||||||
href={`/admin/articles/${a.id}`}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/blog/${a.slug}`}
|
|
||||||
target="_blank"
|
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
|
||||||
title="Открыть на сайте"
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Proxy для /admin/api/articles/:id → engine /api/articles/:id
|
||||||
|
* Используется для DELETE из admin UI.
|
||||||
|
*/
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
import { adminUpdateArticle, adminDeleteArticle } from '@/lib/engine';
|
|
||||||
|
|
||||||
export async function PATCH(req, { params }) {
|
const E = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
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 { id } = await params;
|
||||||
const body = await req.json();
|
const url = `${E}/api/articles/${id}`;
|
||||||
const result = await adminUpdateArticle(id, body);
|
|
||||||
return NextResponse.json(result);
|
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 }) {
|
export const GET = proxy;
|
||||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
export const PATCH = proxy;
|
||||||
const { id } = await params;
|
export const PUT = proxy;
|
||||||
const result = await adminDeleteArticle(id);
|
export const DELETE = proxy;
|
||||||
return NextResponse.json(result);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-1 text-emerald-500 text-xs">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" /> удалена
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/admin/articles/${article.id}`}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/blog/${article.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
||||||
|
title="Открыть на сайте"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={busy}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 text-neutral-400 hover:text-red-500 transition-colors disabled:opacity-40"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Для статуса показываем дополнительно «выйдет в 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 (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400 inline-flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> {label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
status === 'published'
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-amber-50 dark:bg-amber-950 text-amber-600 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{status === 'published' ? 'Опубликована' : 'Черновик'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user