Polish admin articles: card grid, thumbnails, author, slug field
Local Build and Deploy / deploy (push) Successful in 1m14s
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:
1 parent
a07d438987
commit
6215074331
4 files changed
+182
-85
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user