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") ?? "") const image = String(formData.get("image") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const rawSlug = String(formData.get("slug") ?? "").trim();
if (!title) return; if (!title) return;
try { try {
const now = new Date(); const now = new Date();
await prisma.websiteArticles.create({ await prisma.websiteArticles.create({
data: { data: {
slug: await uniqueSlug(title), slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255), title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255), shortStory: shortStory.slice(0, 255),
fullStory, fullStory,
@@ -64,6 +65,7 @@ export async function createArticle(formData: FormData): Promise<void> {
export async function updateArticle(formData: FormData): Promise<void> { export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT); await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim();
try { try {
await prisma.websiteArticles.update({ await prisma.websiteArticles.update({
where: { id }, where: { id },
@@ -72,6 +74,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
...(rawSlug
? { slug: await uniqueSlug(rawSlug) }
: {}),
shortStory: String(formData.get("shortStory") ?? "") shortStory: String(formData.get("shortStory") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
+1
View File
@@ -67,6 +67,7 @@ export default async function EditArticle({
}} }}
defaultValues={{ defaultValues={{
title: article.title, title: article.title,
slug: article.slug,
image: article.image, image: article.image,
shortStory: article.shortStory, shortStory: article.shortStory,
fullStory: article.fullStory, fullStory: article.fullStory,
+93 -42
View File
@@ -23,18 +23,45 @@ export default async function AdminArticles({
const articles = await prisma.websiteArticles const articles = await prisma.websiteArticles
.findMany({ .findMany({
orderBy: { createdAt: "desc" }, 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, take: 50,
}) })
.catch(() => []); .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 const latest = articles[0]?.createdAt
? articles[0].createdAt.toISOString().slice(0, 10) ? articles[0].createdAt.toISOString().slice(0, 10)
: "—"; : "—";
return ( return (
<main> <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"> <Link href="/admin/articles/new" className="btn btn-primary">
+ {t("newArticle")} + {t("newArticle")}
</Link> </Link>
@@ -65,47 +92,71 @@ export default async function AdminArticles({
{articles.length === 0 ? ( {articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div> <div className="admin-empty">{t("noArticles")}</div>
) : ( ) : (
<table> <div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
<thead> {articles.map((a) => (
<tr> <div
<th>{t("colTitle")}</th> key={String(a.id)}
<th>{t("colSlug")}</th> className="admin-card overflow-hidden flex flex-col"
<th>{t("colCreated")}</th> >
<th>{t("colActions")}</th> <div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
</tr> {/* eslint-disable-next-line @next/next/no-img-element */}
</thead> <img
<tbody> src={a.image || ""}
{articles.map((a) => ( alt={a.title}
<tr key={String(a.id)}> className="w-full h-full object-cover block"
<td> onError={(
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link> e: React.SyntheticEvent<HTMLImageElement>,
</td> ) => {
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> e.currentTarget.style.visibility = "hidden";
<code>{a.slug}</code> }}
</td> />
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> </div>
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"} <div className="p-3 flex flex-col flex-1">
</td> <Link
<td> href={`/admin/articles/${a.id}`}
<div className="flex gap-2"> className="font-semibold text-[var(--admin-text)] hover:text-[var(--admin-accent)] line-clamp-2"
<Link style={{ textDecoration: "none" }}
href={`/admin/articles/${a.id}`} >
className="btn btn-primary" {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")} {t("delete")}
</Link> </button>
<form action={deleteArticle} className="m-0"> </form>
<input type="hidden" name="id" value={String(a.id)} /> </div>
<button type="submit" className="btn btn-danger"> </div>
{t("delete")} </div>
</button> ))}
</form> </div>
</div>
</td>
</tr>
))}
</tbody>
</table>
)} )}
</section> </section>
</main> </main>
+82 -42
View File
@@ -1,40 +1,73 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { MediaPicker } from "./media-picker"; import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text"; import { RichText } from "./rich-text";
import { slugify } from "@/lib/format";
export function ArticleForm({ export function ArticleForm({
action, action,
defaultValues, defaultValues,
}: { }: {
action: (formData: FormData) => Promise<void>; action: (formData: FormData) => Promise<void>;
edit?: boolean;
defaultValues?: { defaultValues?: {
title?: string; title?: string;
slug?: string;
image?: string; image?: string;
shortStory?: string; shortStory?: string;
fullStory?: 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 [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const suggestedSlug = useMemo(() => slugify(title), [title]);
return ( return (
<form <form action={action} className="admin-card" style={{ display: "grid", gap: "1rem" }}>
action={action} <div className="grid gap-4 md:grid-cols-2">
className="card" <label className="block">
style={{ display: "grid", gap: "0.6rem" }} <span className="text-xs font-medium text-[var(--admin-text-muted)]">
> {t("title")}
<input </span>
name="title" <input
defaultValue={defaultValues?.title ?? ""} name="title"
placeholder="Title" value={title}
required onChange={(e) => setTitle(e.target.value)}
/> placeholder={t("titlePlaceholder")}
<div> required
<div className="input input-bordered mt-1 w-full"
style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start" }} />
> </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 <input
name="image" name="image"
value={image} value={image}
@@ -42,8 +75,8 @@ export function ArticleForm({
setImage(e.target.value); setImage(e.target.value);
setImageError(false); setImageError(false);
}} }}
placeholder="Image URL" placeholder={t("imagePlaceholder")}
style={{ flex: 1 }} className="input input-bordered flex-1"
/> />
<MediaPicker <MediaPicker
onSelect={(url) => { onSelect={(url) => {
@@ -59,37 +92,44 @@ export function ArticleForm({
src={image} src={image}
alt="preview" alt="preview"
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
marginTop: "0.4rem", style={{ maxHeight: 160 }}
maxHeight: 120,
borderRadius: 6,
objectFit: "cover",
}}
/> />
) : null} ) : null}
{imageError ? ( {imageError ? (
<p <p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
className="muted"
style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}
>
Image not found — enter a valid URL or browse. Image not found — enter a valid URL or browse.
</p> </p>
) : null} ) : null}
</div> </div>
<textarea
name="shortStory" <label className="block">
defaultValue={defaultValues?.shortStory ?? ""} <span className="text-xs font-medium text-[var(--admin-text-muted)]">
placeholder="Short story (summary)" {t("body")}
rows={2} </span>
/> <textarea
<RichText name="shortStory"
name="fullStory" defaultValue={defaultValues?.shortStory ?? ""}
defaultValue={defaultValues?.fullStory ?? ""} placeholder={t("bodyPlaceholder")}
placeholder="Full story" rows={2}
rows={10} className="input input-bordered mt-1 w-full resize-y"
/> />
<button type="submit" className="btn btn-primary"> </label>
{defaultValues ? "Save" : "Create"}
<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> </button>
</form> </form>
); );