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

+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>
);