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

+6 -1
View File
@@ -36,13 +36,14 @@ export async function createArticle(formData: FormData): Promise<void> {
const image = String(formData.get("image") ?? "")
.normalize("NFC")
.trim();
const rawSlug = String(formData.get("slug") ?? "").trim();
if (!title) return;
try {
const now = new Date();
await prisma.websiteArticles.create({
data: {
slug: await uniqueSlug(title),
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
@@ -64,6 +65,7 @@ export async function createArticle(formData: FormData): Promise<void> {
export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim();
try {
await prisma.websiteArticles.update({
where: { id },
@@ -72,6 +74,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, 255),
...(rawSlug
? { slug: await uniqueSlug(rawSlug) }
: {}),
shortStory: String(formData.get("shortStory") ?? "")
.normalize("NFC")
.trim()
+1
View File
@@ -67,6 +67,7 @@ export default async function EditArticle({
}}
defaultValues={{
title: article.title,
slug: article.slug,
image: article.image,
shortStory: article.shortStory,
fullStory: article.fullStory,
+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>
+82 -42
View File
@@ -1,40 +1,73 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text";
import { slugify } from "@/lib/format";
export function ArticleForm({
action,
defaultValues,
}: {
action: (formData: FormData) => Promise<void>;
edit?: boolean;
defaultValues?: {
title?: string;
slug?: string;
image?: string;
shortStory?: string;
fullStory?: string;
};
}) {
const t = useTranslations("pages.admin.articles.form");
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
const suggestedSlug = useMemo(() => slugify(title), [title]);
return (
<form
action={action}
className="card"
style={{ display: "grid", gap: "0.6rem" }}
>
<input
name="title"
defaultValue={defaultValues?.title ?? ""}
placeholder="Title"
required
/>
<div>
<div
style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start" }}
>
<form action={action} className="admin-card" style={{ display: "grid", gap: "1rem" }}>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("title")}
</span>
<input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
className="input input-bordered mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder={t("slugPlaceholder")}
className="input input-bordered mt-1 w-full"
/>
</label>
</div>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
name="image"
value={image}
@@ -42,8 +75,8 @@ export function ArticleForm({
setImage(e.target.value);
setImageError(false);
}}
placeholder="Image URL"
style={{ flex: 1 }}
placeholder={t("imagePlaceholder")}
className="input input-bordered flex-1"
/>
<MediaPicker
onSelect={(url) => {
@@ -59,37 +92,44 @@ export function ArticleForm({
src={image}
alt="preview"
onError={() => setImageError(true)}
style={{
marginTop: "0.4rem",
maxHeight: 120,
borderRadius: 6,
objectFit: "cover",
}}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p
className="muted"
style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}
>
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p>
) : null}
</div>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder="Short story (summary)"
rows={2}
/>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder="Full story"
rows={10}
/>
<button type="submit" className="btn btn-primary">
{defaultValues ? "Save" : "Create"}
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</div>
<button type="submit" className="btn btn-primary justify-self-start">
{t("save")}
</button>
</form>
);