Polish admin articles: card grid, thumbnails, author, slug field
Local Build and Deploy / deploy (push) Successful in 1m14s

Replace the plain articles table with a responsive card grid showing
thumbnails, title, date and author. Add a slug field to the article form
with live auto-suggestion, i18n-driven labels, a larger image preview and
delete confirmation. Persist a user-provided slug (falls back to an
auto-generated one) on create and update.
This commit is contained in:
openhands committed 2026-07-18 17:38:01 +02:00
1 parent a07d438987
commit 6215074331
4 files changed
+182 -85

No files matched your search

+93 -42
View File
@@ -23,18 +23,45 @@ export default async function AdminArticles({
const articles = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, title: true, slug: true, createdAt: true },
select: {
id: true,
title: true,
slug: true,
image: true,
createdAt: true,
userId: true,
},
take: 50,
})
.catch(() => []);
const authorMap = new Map<number, string>();
const authorIds = articles.map((a) => a.userId).filter((id): id is number => Boolean(id));
if (authorIds.length > 0) {
const authors = await prisma.user
.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true },
})
.catch(() => []);
for (const u of authors) authorMap.set(u.id, u.username);
}
const latest = articles[0]?.createdAt
? articles[0].createdAt.toISOString().slice(0, 10)
: "—";
return (
<main>
<div className="flex justify-end mb-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-sm text-[var(--admin-text-muted)] mt-1">
{t("subtitle")}
</p>
</div>
<Link href="/admin/articles/new" className="btn btn-primary">
+ {t("newArticle")}
</Link>
@@ -65,47 +92,71 @@ export default async function AdminArticles({
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colTitle")}</th>
<th>{t("colSlug")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)}>
<td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{a.slug}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<div className="flex gap-2">
<Link
href={`/admin/articles/${a.id}`}
className="btn btn-primary"
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{articles.map((a) => (
<div
key={String(a.id)}
className="admin-card overflow-hidden flex flex-col"
>
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={a.image || ""}
alt={a.title}
className="w-full h-full object-cover block"
onError={(
e: React.SyntheticEvent<HTMLImageElement>,
) => {
e.currentTarget.style.visibility = "hidden";
}}
/>
</div>
<div className="p-3 flex flex-col flex-1">
<Link
href={`/admin/articles/${a.id}`}
className="font-semibold text-[var(--admin-text)] hover:text-[var(--admin-accent)] line-clamp-2"
style={{ textDecoration: "none" }}
>
{a.title}
</Link>
<p className="text-xs text-[var(--admin-text-muted)] mt-1 mb-3 flex items-center gap-2 flex-wrap">
<span>{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}</span>
{authorMap.get(a.userId ?? -1) ? (
<>
<span>·</span>
<span>{authorMap.get(a.userId ?? -1)}</span>
</>
) : null}
</p>
<div className="flex gap-2 mt-auto">
<Link
href={`/admin/articles/${a.id}`}
className="btn btn-primary flex-1 text-center"
>
{t("edit")}
</Link>
<form action={deleteArticle} className="m-0">
<input type="hidden" name="id" value={String(a.id)} />
<button
type="submit"
className="btn btn-danger"
onClick={(e) => {
if (
!window.confirm(
t("confirmDelete", { title: a.title }),
)
)
e.preventDefault();
}}
>
{t("edit")}
</Link>
<form action={deleteArticle} className="m-0">
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
{t("delete")}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
{t("delete")}
</button>
</form>
</div>
</div>
</div>
))}
</div>
)}
</section>
</main>