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:
Aleksei Pavlov
2026-06-19 20:55:34 +03:00
parent 4151894935
commit c7e9196370
3 changed files with 130 additions and 37 deletions
+95
View File
@@ -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>
);
}